diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 5f52226f..f67a7db7 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,5 +1,6 @@ name: "Bug Report" description: Report a bug +title: "[BUG] - " labels: [ "bug" ] @@ -22,22 +23,12 @@ 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: label: "App Version" description: What version of the app? - placeholder: "v0.5.1" + placeholder: "v0.3.0" validations: required: true - type: input @@ -45,7 +36,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.6" + placeholder: "10.11.3" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 389d4e8a..2dc73ff6 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,5 +1,6 @@ name: "Playback Problem" description: Report an issue with playback +title: "[BUG] - <title>" labels: [ "bug", "playback" ] @@ -11,14 +12,11 @@ body: description: Please enter a description of the playback issue validations: required: true - - type: dropdown - id: experimental + - type: textarea + id: mediainfo attributes: - label: Experimental settings - description: Do you have experiment settings turn on? - options: - - "Yes" - - "No" + label: "Media info" + description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) validations: required: true - type: dropdown @@ -30,15 +28,6 @@ body: options: - ExoPlayer (default) - MPV - - 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 @@ -46,7 +35,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.5.1" + placeholder: "v0.3.0" validations: required: true - type: input @@ -54,7 +43,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.6" + placeholder: "10.11.3" validations: required: true - type: input diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/ISSUE_TEMPLATE/02-new-feature.yml similarity index 75% rename from .github/DISCUSSION_TEMPLATE/ideas.yml rename to .github/ISSUE_TEMPLATE/02-new-feature.yml index 5d1b4346..500beea4 100644 --- a/.github/DISCUSSION_TEMPLATE/ideas.yml +++ b/.github/ISSUE_TEMPLATE/02-new-feature.yml @@ -1,4 +1,6 @@ -title: "<title>" +name: "Feature Request" +description: Request a new feature +title: "[FEA] - <title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml new file mode 100644 index 00000000..bd10bd46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-question.yml @@ -0,0 +1,15 @@ +name: "Question" +description: Ask a question +title: "[QST] - <title>" +labels: [ + "question" +] +body: + - type: textarea + id: description + attributes: + label: "Question" + description: Please enter your question + placeholder: "How do I...?" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 2ee91c39..00000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: New feature / UI suggestion - url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas - about: Please request new features here - - name: Questions - url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a - about: Please ask questions here diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml new file mode 100644 index 00000000..1f35c259 --- /dev/null +++ b/.github/actions/native-build/action.yml @@ -0,0 +1,79 @@ +name: "Native build" +description: "Performs native builds" +inputs: + cache: + description: "Whether to use the cache or not" + required: true +runs: + using: "composite" + steps: + - name: Load ffmpeg module cache + id: cache_ffmpeg_module + if: ${{ inputs.cache }} + uses: actions/cache/restore@v4 + with: + path: | + app/libs + key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} + - name: Build ffmpeg decoder + id: ffmpeg-decoder + if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' + shell: bash + run: | + cd scripts/ffmpeg + ./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}" + - name: Save ffmpeg module cache + id: cache_ffmpeg_module_save + uses: actions/cache/save@v4 + with: + path: | + app/libs + key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} + + - name: Load libmpv module cache + id: cache_libmpv_module + if: ${{ inputs.cache }} + uses: actions/cache/restore@v4 + with: + path: | + app/src/main/libs + key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} + + - name: Install dependencies + if: steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + python -m pip install --upgrade pip + pip install jsonschema jinja2 + sudo apt update + sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson + - name: Get libmpv dependencies + if: steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + cd scripts/mpv + ./get_dependencies.sh + - name: Build libmpv dependencies + if: steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + cd scripts/mpv + ./buildall.sh --clean --arch arm64 mpv + ./buildall.sh mpv + cd ../.. + env PREFIX32="$(realpath scripts/mpv/prefix/armv7l)" PREFIX64="$(realpath scripts/mpv/prefix/arm64)" ndk-build -C app/src/main -j + + - name: Setup jniLibs + shell: bash + run: | + cd app/src/main + mkdir -p jniLibs + cp -r libs/* jniLibs + #ln -s libs jniLibs + - name: Save libmpv module cache + id: cache_libmpv_module_save + uses: actions/cache/save@v4 + with: + path: | + app/src/main/libs + key: ${{ steps.cache_libmpv_module.outputs.cache-primary-key }} diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 7cd04ce3..1153cc97 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@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 + uses: actions/setup-python@v5 with: - python-version: '3.14' + python-version: '3.12' - name: Setup JDK - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 + uses: actions/setup-java@v5 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@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4 + uses: android-actions/setup-android@v3 with: - packages: "platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" + packages: "tools platform-tools build-tools;${{ env.BUILD_TOOLS_VERSION }} ndk;${{ env.NDK_VERSION }}" - name: Add NDK to path shell: bash run: | diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 3043d994..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,17 +0,0 @@ -<!-- By submitting this pull request, you acknowledge that you have read the [contributing guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md, including the AI/LLM policy, and [developer's guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md) --> - -## Description -<!-- Describe the changes in detail --> - -### Related issues -<!-- If this is a new feature or a change, there must be a discussion in an issue first, reference the issue here --> -<!-- If fixing a bug, reference the bug issue here, or describe the bug, including steps to reproduce --> - -### Testing -<!-- Describe how this change was tested and on what device(s) --> - -## Screenshots -<!-- Please include screenshots if the PR alters any UI elements --> - -## AI or LLM usage -<!-- If you used any AI or LLM assistance, please list where in the code and how you tested it --> diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cafd4889..539f0572 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='-Xmx12g -XX:MaxMetaspaceSize=1024m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'" jobs: build: @@ -25,11 +25,15 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@v5 with: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup + - name: Native build + uses: ./.github/actions/native-build + with: + cache: true - name: Get version names run: | @@ -51,12 +55,8 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" - ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" - ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean assembleDefaultDebug - ./gradlew assembleDefaultRelease - + ./gradlew clean assembleRelease assembleDebug --no-daemon - name: Verify signatures run: | echo "Verify APK signatures" @@ -68,27 +68,20 @@ jobs: find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//' | sed -E 's/-default//')" + short_name="$(echo $line | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" echo "$line => $short_name" cp "$line" "$short_name" done + apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - - name: Upload mapping files - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: mapping - path: | - app/build/outputs/**/mapping.txt - compression-level: 6 - name: Delete ${{ env.TAG_NAME }} tag and release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - shopt -s globstar - gh release delete "${{ env.TAG_NAME }}" --cleanup-tag -y || true @@ -105,4 +98,4 @@ jobs: gh release create "${{ env.TAG_NAME }}" \ --latest=false --prerelease --title "${{ env.VERSION_NAME }}" --target "${{ env.BRANCH_NAME }}" -F ci_release_note.txt \ - app/build/outputs/apk/**/*.apk + "app/build/outputs/apk/**/*.apk" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e423cccf..d81c5e18 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,36 +9,54 @@ defaults: env: BUILD_DIRS_ARTIFACT: build-dirs - GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx12g -XX:MaxMetaspaceSize=1024m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'" jobs: pre-commit: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@v5 with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 + uses: actions/setup-python@v6 with: - python-version: '3.14' + python-version: '3.12' - name: Run pre-commit - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + uses: pre-commit/action@v3.0.1 build: runs-on: ubuntu-latest needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@v5 with: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup + - name: Native build + uses: ./.github/actions/native-build + with: + cache: true - name: Build app id: buildapp run: | - ./gradlew clean assembleDefaultDebug testDefaultDebugUnitTest --no-daemon - apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') + ./gradlew clean assembleDebug --no-daemon + apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" + - name: Tar build dirs + run: | + tar -czf build.tgz ./app/. + - uses: actions/upload-artifact@v5 + id: upload-build-dirs + with: + name: "${{ env.BUILD_DIRS_ARTIFACT }}" + path: build.tgz + if-no-files-found: error + - uses: actions/upload-artifact@v5 + with: + name: APKs + path: "${{ steps.buildapp.outputs.apks }}" + compression-level: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b108ce5..7c5c45f9 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='-Xmx12g -XX:MaxMetaspaceSize=1024m'" + GRADLE_OPTS: "-Dorg.gradle.jvmargs='-Xmx4g -XX:MaxMetaspaceSize=512m'" TAG_NAME: ${{ github.ref_name }} jobs: @@ -21,11 +21,15 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@v5 with: fetch-depth: 0 # Need the tags to build - name: Setup uses: ./.github/actions/setup + - name: Native build + uses: ./.github/actions/native-build + with: + cache: false - name: Build app id: buildapp env: @@ -33,51 +37,33 @@ jobs: KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" - ORG_GRADLE_PROJECT_WholphinExtensionsUsername: "${{ secrets.EXTENSIONS_USERNAME }}" - ORG_GRADLE_PROJECT_WholphinExtensionsPassword: "${{ secrets.EXTENSIONS_PASSWORD }}" run: | - ./gradlew clean bundleAppstoreRelease - ./gradlew bundleFiretvRelease - ./gradlew assembleDefaultRelease + ./gradlew clean assembleRelease --no-daemon - name: Verify signatures run: | echo "Verify APK signatures" - find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) - find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + find app/build/outputs/apk -name '*.apk' + find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | find app/build/outputs/apk -name '*.apk' -print0 | while IFS= read -r -d '' line; do - # Wholphin-default-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk - short_name="$(echo $line | sed -E 's/-default-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" + # Wholphin-release-0.2.9-62-g5c12e03-16-arm64-v8a.apk => Wholphin-arm64-v8a.apk + short_name="$(echo $line | sed -E 's/-release-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-g[a-fA-F0-9]+-[0-9]+//')" echo "$line => $short_name" cp "$line" "$short_name" done + apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - - name: Upload AAB - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: AAB - path: | - app/build/outputs/bundle/**/*.aab - compression-level: 0 - - name: Upload mapping files - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: mapping - path: | - app/build/outputs/**/mapping.txt - compression-level: 6 + find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - shopt -s globstar - gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ - app/build/outputs/apk/**/*.apk + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ + "app/build/outputs/apk/**/*.apk" diff --git a/.gitignore b/.gitignore index 695422b8..a5248a5b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,9 +53,7 @@ app/libs/ ffmpeg_decoder/ app/release/ app/debug/ -.vscode -.kotlin -docs/ +<<<<<<< HEAD # mpv app/src/main/obj/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75d48ceb..12ce8583 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 + rev: v5.0.0 hooks: - id: check-xml - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.15.0 + rev: v2.12.0 hooks: - id: pretty-format-kotlin - args: [ --autofix, --ktlint-version=1.8.0 ] + args: [ --autofix, --ktlint-version=1.7.1 ] - repo: local hooks: - id: check-debug-enabled @@ -18,8 +18,3 @@ repos: entry: val DEBUG = true language: pygrep files: '.+\.kt' - - id: check-debug-logging-enabled - name: Check debug enabled - entry: debugLogging = true - language: pygrep - files: '.+\.kt' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3eee4ee9..9c8484fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,10 +23,3 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features. See the [developer's guide](./DEVELOPMENT.md) for more information. - - -#### Policy for AI or LLMs - -Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words. - -At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0d818e21..6d075fd8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,7 +14,6 @@ The app uses: * [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage * [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection * [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback -* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback * [Coil](https://coil-kt.github.io/coil/) for image loading * [OkHttp](https://square.github.io/okhttp/) for HTTP requests @@ -24,27 +23,9 @@ We follow GitHub's fork & pull request model for contributions. After forking and cloning your fork, you can import the project into Android Studio. -### Development environment +You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml). -It is recommended to use a recent version of [Android Studio](https://developer.android.com/studio). Make sure the [version is compatible](https://developer.android.com/build/releases/gradle-plugin#android_gradle_plugin_and_android_studio_compatibility) with Wholphin's AGP version. - -Code formatting should follow [ktlint's](https://github.com/pinterest/ktlint) rules. Find the `ktlint` version in [`.pre-commit-config.yaml`](./.pre-commit-config.yaml). Optionally, install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) in Android Studio to run automatically. Configure the version in `Settings->Tools->KtLint->Ruleset Version`. - -Also, it's recommend to add an extra ruleset jar for Compose-specific KtLint: https://mrmans0n.github.io/compose-rules/ktlint/#using-with-ktlint-cli-or-the-ktlint-intellij-plugin - -Also setup [pre-commit](https://github.com/pre-commit/pre-commit) which will run `ktlint` as well on each commit, plus check for other common issues. - -#### Extensions - -Wholphin uses several native components for extra playback compatibility. This includes Media3 ffmpeg/av1 decoders and `libmpv`. These extensions are not required to build the app, but without them some functionality will not work. - -If you want to include these in a local build, see the [instructions here](https://github.com/damontecres/wholphin-extensions?tab=readme-ov-file#usage) for configuring the repository. - -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 organization Code is split into several packages: - `data` - app-specific data models and services @@ -53,53 +34,18 @@ Code is split into several packages: - `ui` - User interface code and ViewModels - `util` - Utility classes and functions -### Settings +### Native components -There are a few different ways user settings are stored: -1. `AppPreferences` via DataStore -2. Room database -3. Key-Value DataStore +#### FFmpeg decoder module -#### App preferences +Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md). -These are generally settings that apply across the whole app regardless of the currently active user. +It is not required to build the extension in order to build the app locally. -The `AppPreferences` object can be retrieved from the `UserPreferencesService` or directly via injecting `DataStore<AppPreferences>`. +You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](./scripts/ffmpeg/build_ffmpeg_decoder.sh) script. -The `AppPreference` (note the `s` differences) objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`. +#### MPV player backend -##### How to add a new app setting +Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`. -1. Add entry in `WholphinDataStore.proto` & build to generate classes -2. Add new `AppPreference` object in `AppPreference.kt` -3. Add new object to a `PreferenceGroup` (listed in `AppPreference.kt`) -4. Update `AppPreferencesSerializer` to set the default value for new installs -5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades - - Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different - -#### Room settings - -These are settings were generally are applied per user. They are stored in several different tables. The entities are defined in `com.github.damontecres.wholphin.data.model`. - -Additionally, all server and user info for both Jellyfin and Seerr are stored in Room tables. - -Some examples are: -- `JellyfinServer` -- `JellyfinUser` -- `SeerrServer` -- `LibraryDisplayInfo` -- `ItemPlayback` - -Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSON in a column instead of defined entities. This should be used sparingly. - -#### Key-value settings - -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. +See the [build scripts](scripts/mpv/) for details on building this component. diff --git a/Intents.md b/Intents.md deleted file mode 100644 index 7e358955..00000000 --- a/Intents.md +++ /dev/null @@ -1,168 +0,0 @@ -# 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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv/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 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \ - --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" -``` diff --git a/Makefile b/Makefile deleted file mode 100644 index df6bd824..00000000 --- a/Makefile +++ /dev/null @@ -1,21 +0,0 @@ -.DEFAULT_GOAL := apk -WOLFSKIN_BUILD_IMAGE ?= wolfskin-build:sdk37-jdk21 -WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle -WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin -export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR - -.PHONY: build-image apk apk-debug test release-tag -build-image: - docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker - -apk: build-image - @bash docker/gradle.sh release clean assembleDefaultRelease - -apk-debug: build-image - @bash docker/gradle.sh debug assembleDefaultDebug - -test: build-image - @bash docker/gradle.sh debug testDefaultDebugUnitTest - -release-tag: - @bash docker/release-tag.sh diff --git a/README.md b/README.md index b8f04e12..e333c763 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,40 @@ -# Wolfskin - -Wolfskin is a personal fork of [Wholphin](https://github.com/damontecres/Wholphin) by damontecres. - -Licensed under GPL-2.0, the same licence as upstream (see [LICENSE](LICENSE)). - -Wolfskin is an Android TV client for one household. It is released through Forgejo releases on this repo, and it tracks upstream by merging each upstream release tag. - -The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres. - ---- - # Wholphin - an OSS Android TV client for Jellyfin > "Never half-phin two jellies. Always wholphin one jelly." Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin. -This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV. +This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental). <p align="center"> +<a href="https://github.com/damontecres/Wholphin/issues/303"><b>Help get Wholphin listed on the Play Store</b></a> +<br/> +<br/> <a href="https://github.com/damontecres/Wholphin/releases"> <img alt="Current Release" src="https://img.shields.io/github/release/damontecres/wholphin.svg"/> </a> <a href="https://translate.codeberg.org/engage/wholphin/"> <img src="https://translate.codeberg.org/widget/wholphin/wholphin/svg-badge.svg" alt="Translation status" /> </a> -<br/> -<a href="https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin"> -<img width="180" alt="Get Wholphin on Google Play" src="https://github.com/user-attachments/assets/2550a4cb-ce46-47a1-ae24-f33a169234b7"/> -</a> -<a href="https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin"> -<img width="180" alt="Get Wholphin on Amazon AppStore" src="https://github.com/user-attachments/assets/1f3a3b26-4b4f-44b1-9741-f4c895c8a53b"/> -</a> - - </p> -![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) +<img width="1280" height="771" alt="0 3 0_home" src="https://github.com/user-attachments/assets/23be4786-a4c3-45e5-a710-8d1ade2be2a3" /> ## Features -### User interface - -- Customize the home page to see the content you are interested in - - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - - Option to combine the Continue Watching & Next Up into a single row - - Pin collections, playlists, favorites, genres, and studios - - Remove series from next up -- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app -- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - - Note: only available when installed from [GitHub](https://github.com/damontecres/Wholphin/releases/latest) or the [Play store](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) -- Customize library display - - Option to show Movie/TV Show titles - - Choose image types & size - - Choose from grid or list layouts -- Play theme music, if available -- Customize subtitle style for plain text subtitles with separate HDR settings -- Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles)) -- Multiple app color themes -- Protect user profile switches with PIN code or require server login -- Change the user interface language per user -- In-app & Android OS screensaver support - -### Playback - -- Different media playback engines: - - **ExoPlayer** w/ optional extra audio, SSA/ASS, & AV1 software decoding - - **MPV** for direct playing anything with great SSA/ASS subtitle support -- Plex inspired playback controls: +- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app +- Show Movie/TV Show titles when browsing libraries +- Play TV Show theme music, if available +- Plex inspired playback controls, such as: - Using D-Pad left/right for seeking during playback - - Quickly access video chapters & queue during playback + - Quickly access video chapters during playback - Optionally skip back a few seconds when resuming playback -- Live TV & DVR support -- Music & music video support -- Cinema mode/pre-roll intro support (requires compatible server plugin such as [Intros](https://github.com/jellyfin/jellyfin-plugin-intros)) -- Auto play next episodes with pass out protection -- Option for automatic refresh rate & resolution switching on supported displays -- Trickplay (video preview) support -- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad +- Other (subjective) enhancements: + - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad + - Force Continue Watching & Next Up TV episodes to use their Series posters +- Different media playback engines, including: + - Default ExoPlayer/Media3 + - Experimental MPV ### Roadmap @@ -85,8 +42,6 @@ See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) ## Installation -Using [Google Play](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) or [Amazon appstore](https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin) are the fastest way to install. But you can follow these instructions to install without needing an app store - Downloader Code: `8668671` 1. Enable side-loading "unknown" apps @@ -94,7 +49,7 @@ Downloader Code: `8668671` - https://www.xda-developers.com/how-to-sideload-apps-android-tv/ - https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources - https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/ -2. Install the APK on your Android TV device with one of these options: +1. Install the APK on your Android TV device with one of these options: - Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `8668671` or URL: http://aftv.news/8668671 - Download the latest APK release from the [releases page](https://github.com/damontecres/Wholphin/releases/latest) or http://aftv.news/8668671 - Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work! @@ -107,16 +62,12 @@ After the initial install above, the app will automatically check for updates. T The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates. -Note: if installed via an app store, the app store will handle updates. - ## Compatibility -Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11`). +Requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`). 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 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 Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. @@ -134,21 +85,11 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ## Additional screenshots -### Customized home page -![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) - ### Movie library browsing -![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) - -### Movie page -![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) +<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> ### Series page -![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) - -### Genres in library -![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) - +<img width="1280" height="771" alt="0 3 0_series_overview" src="https://github.com/user-attachments/assets/2d26f34e-3ee8-4599-ba59-32524637dab7" /> ### Playlist -![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) +<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 98f03a3a..c92407dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,17 +1,13 @@ -import com.android.build.api.artifact.SingleArtifact -import com.android.build.api.dsl.ApplicationExtension -import com.android.build.api.dsl.ProductFlavor -import com.android.build.api.variant.FilterConfiguration import com.google.protobuf.gradle.id import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.io.File +import java.io.ByteArrayOutputStream import java.util.Base64 import java.util.Properties plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) @@ -19,74 +15,82 @@ plugins { alias(libs.plugins.protobuf) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.aboutLibraries) - alias(libs.plugins.openapi.generator) } -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 = - providers.provider { - gradle.startParameter.taskNames.any { it.lowercase().contains("bundle") } - } - -val gitTags = - providers - .exec { commandLine("git", "tag", "--list", "v*", "p*") } - .standardOutput.asText - .get() - -val gitDescribe = - providers - .exec { commandLine("git", "describe", "--tags", "--long", "--match=v*") } - .standardOutput.asText - .getOrElse("v0.0.0") - -kotlin { - compilerOptions { - 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}" +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() + +fun getVersionCode(): Int { + val stdout = ByteArrayOutputStream() + exec { + commandLine = listOf("git", "tag", "--list", "v*", "p*") + standardOutput = stdout } + return stdout + .toString() + .trim() + .lines() + .size } -private fun Provider<String>.getInt() = get().toInt() +fun getAppVersion(): String { + val stdout = ByteArrayOutputStream() + exec { + commandLine = listOf("git", "describe", "--tags", "--long", "--match=v*") + standardOutput = stdout + } + return stdout + .toString() + .trim() + .removePrefix("v") + .ifBlank { "0.0.0" } +} -configure<ApplicationExtension> { +android { namespace = "com.github.damontecres.wholphin" - compileSdk = libs.versions.compileSdk.getInt() + compileSdk = 36 defaultConfig { - applicationId = "org.justinvisser.wolfskin.tv" - 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" - - buildConfigField("long", "BUILD_TIME", System.currentTimeMillis().toString()) - buildConfigField("String", "CONDUCTARR_BASE_URL", "\"https://wolfskin-tv.justinvisser.org\"") + applicationId = "com.github.damontecres.wholphin" + minSdk = 25 + targetSdk = 36 + versionCode = getVersionCode() + versionName = getAppVersion() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + isDebuggable = false + } + debug { + isMinifyEnabled = false + isDebuggable = true + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + buildConfig = true + compose = true + } + room { + schemaDirectory("$projectDir/schemas") + } signingConfigs { - if (shouldSign.get()) { + if (shouldSign) { create("ci") { file("ci.keystore").writeBytes( Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), @@ -102,17 +106,15 @@ configure<ApplicationExtension> { } } } - buildTypes { release { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) - isDebuggable = false - if (shouldSign.get()) { + if (shouldSign) { signingConfig = signingConfigs.getByName("ci") } else { val localPropertiesFile = project.rootProject.file("local.properties") @@ -126,170 +128,33 @@ configure<ApplicationExtension> { } } } - debug { - isMinifyEnabled = false - isShrinkResources = false - isDebuggable = true - applicationIdSuffix = ".debug" + if (shouldSign) { + signingConfig = signingConfigs.getByName("ci") + } } - create("optimized") { - initWith(getByName("release")) - signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".opt" - matchingFallbacks += "release" + applicationVariants.all { + val variant = this + variant.outputs + .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } + val outputFileName = + "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" + output.outputFileName = outputFileName + } } } - flavorDimensions += "version" - productFlavors { - val featureLeanback = "leanback" - val featureUpdate = "UPDATING_ENABLED" - val featureDiscover = "DISCOVER_ENABLED" - - fun ProductFlavor.setFeatureFlag( - name: String, - enabled: Boolean, - ) { - this.buildConfigField("boolean", name, "Boolean.parseBoolean(\"${enabled}\")") - } - create("default") { - dimension = "version" - isDefault = true - manifestPlaceholders += mapOf(featureLeanback to false) - setFeatureFlag(featureUpdate, true) - setFeatureFlag(featureDiscover, true) - } - create("appstore") { - dimension = "version" - manifestPlaceholders += mapOf(featureLeanback to true) - setFeatureFlag(featureUpdate, false) - setFeatureFlag(featureDiscover, true) - } - create("firetv") { - dimension = "version" - manifestPlaceholders += mapOf(featureLeanback to true) - setFeatureFlag(featureUpdate, false) - setFeatureFlag(featureDiscover, false) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - isCoreLibraryDesugaringEnabled = true - } - - buildFeatures { - buildConfig = true - compose = true - } splits { abi { - // Disable split abis when building bundles - isEnable = !isBuildingBundle.get() - + isEnable = true reset() - include("armeabi-v7a", "arm64-v8a", "x86_64") + include("armeabi-v7a", "arm64-v8a") isUniversalApk = true } } - packaging { - jniLibs { - // Work around because libass-android & wholphin-mpv both (incorrectly) package libc++_shared.so - pickFirsts += "lib/*/libc++_shared.so" - } - } - - sourceSets { - getByName("main") { - kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" - kotlin.directories += "src/wolfskin/java" - } - getByName("optimized") { - kotlin.directories += "src/release/java" - baselineProfiles.directories += "src/optimized/baselineProfiles" - } - } - - testOptions { - unitTests { - isIncludeAndroidResources = true - } - } - - lint { - disable.add("MissingTranslation") - } - androidResources { - generateLocaleConfig = true - } -} - -androidComponents { - onVariants(selector().all()) { variant -> - variant.outputs - .map { it as com.android.build.api.variant.impl.VariantOutputImpl } - .forEach { output -> - val abi = - output - .getFilter(FilterConfiguration.FilterType.ABI) - .let { if (it != null) "-${it.identifier}" else "" } - val outputFileName = - "Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk" - output.outputFileName = outputFileName - } - if (variant.name == "defaultOptimized") { - tasks.withType<Test>().configureEach { - systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get()) - } - } - if (variant.name == "defaultDebug") { - val packagedApks = variant.artifacts.get(SingleArtifact.APK) - val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() - val aapt2 = sdkComponents.aapt2.flatMap { it.executable } - val checkWolfskinBranding = tasks.register("checkWolfskinBranding") { - inputs.dir(packagedApks) - inputs.file(aapt2) - doLast { - val artifacts = checkNotNull(artifactLoader.load(packagedApks.get())) { - "Packaged default/debug APK metadata was not found; branding is unverified" - }.elements - check(artifacts.isNotEmpty()) { "No default/debug APKs were found; branding is unverified" } - val header = Regex("""^\s*resource 0x[\da-fA-F]+ (?:[^\s:]+:)?([^\s/]+)/([^\s]+)""") - val visibleTypes = setOf("string", "plurals", "array") - // Upstream legal attribution and its project URL must survive the branding cutover. - val legalNames = setOf("wolfskin_upstream_credit", "wolfskin_upstream_url") - val violations = sortedSetOf<String>() - artifacts.forEach { artifact -> - val dump = providers.exec { - commandLine(aapt2.get().asFile, "dump", "resources", artifact.outputFile) - }.standardOutput.asText.get() - var currentName: String? = null - var inspected = 0 - dump.lineSequence().forEach { line -> - val resource = header.find(line) - if (resource != null) { - currentName = resource.groupValues[2].takeIf { resource.groupValues[1] in visibleTypes } - if (currentName != null) inspected++ - } else if (currentName != null && currentName !in legalNames && line.contains("Wholphin", ignoreCase = true)) { - violations += "${File(artifact.outputFile).name}:$currentName" - } - } - check(inspected > 0) { "No packaged string resources were decoded; branding is unverified" } - logger.lifecycle("Inspected $inspected string/plural/array resources in ${File(artifact.outputFile).name}") - } - check(violations.isEmpty()) { "User-visible upstream branding remains: ${violations.joinToString()}" } - logger.lifecycle("Wolfskin branding check passed (${artifacts.size} packaged debug APKs; legal attribution preserved)") - } - } - tasks.matching { it.name == "assembleDefaultDebug" }.configureEach { dependsOn(checkWolfskinBranding) } - } - } -} - -room { - schemaDirectory("$projectDir/schemas") } protobuf { @@ -321,36 +186,7 @@ aboutLibraries { } } -openApiGenerate { - generatorName.set("kotlin") - inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml") - templateDir.set("$projectDir/src/main/seerr/templates") - outputDir.set("$buildDir/generated/seerr_api") - apiPackage.set("com.github.damontecres.wholphin.api.seerr") - modelPackage.set("com.github.damontecres.wholphin.api.seerr.model") - groupId.set("com.github.damontecres.wholphin.api.seerr") - id.set("seerr-api") - packageName.set("com.github.damontecres.wholphin.api.seerr") - additionalProperties.apply { - put("serializationLibrary", "kotlinx_serialization") - put("sortModelPropertiesByRequiredFlag", true) - put("sortParamsByRequiredFlag", true) - put("useCoroutines", true) - put("enumPropertyNaming", "UPPERCASE") - put("modelMutable", false) - - // Note: this is only for downloading files, so it's not necessary to enable - put("supportAndroidApiLevel25AndBelow", false) - } -} - -tasks.named("preBuild") { - dependsOn.add(tasks.named("openApiGenerate")) -} - dependencies { - implementation(project(":wolfskin-design")) - implementation(project(":conductarr-client")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(platform(libs.androidx.compose.bom)) @@ -358,27 +194,19 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) - implementation(libs.androidx.datastore.preferences) implementation(libs.protobuf.kotlin.lite) - implementation(libs.androidx.tvprovider) - implementation(libs.androidx.work.runtime.ktx) - implementation(libs.androidx.hilt.work) - implementation(libs.androidx.profileinstaller) implementation(libs.androidx.media3.exoplayer) - implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) - implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) - implementation(libs.ass.media) - implementation(libs.exoplayerhdrutils) implementation(libs.coil.core) implementation(libs.coil.compose) @@ -402,15 +230,12 @@ 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) - implementation(libs.androidx.palette.ktx) - implementation(libs.androidx.media3.effect) - implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) - ksp(libs.androidx.hilt.compiler) implementation(libs.timber) implementation(libs.slf4j2.timber) @@ -424,64 +249,13 @@ dependencies { implementation(libs.acra.limiter) compileOnly(libs.auto.service.annotations) ksp(libs.auto.service.ksp) - implementation(platform(libs.okhttp.bom)) - implementation(libs.okhttp) - implementation(libs.kache) - implementation(libs.kache.file) - coreLibraryDesugaring(libs.desugar.jdk.libs) - - if (ffmpegModuleExists.get()) { - logger.info("Using local ffmpeg decoder") - implementation(files("libs/lib-decoder-ffmpeg-release.aar")) - } 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.get()) { - logger.info("Using local av1 decoder") - implementation(files("libs/lib-decoder-av1-release.aar")) - } 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.get()) { - logger.info("Using local libMPV build") - implementation(files("libs/wholphin-mpv-release.aar")) - } else if (extensionsRepoActive.get()) { - logger.info("Using prebuilt libMPV") - implementation(libs.wholphin.extensions.mpv) - } else { - 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) - testImplementation("com.squareup.okhttp3:mockwebserver3") - testImplementation(libs.androidx.core.testing) - testImplementation(libs.robolectric) - testImplementation(libs.hilt.android.testing) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") - testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.mockk.android) - androidTestImplementation(libs.hilt.android.testing) - androidTestImplementation(libs.androidx.compose.ui.test.manifest) -} - -tasks.withType<Test>().configureEach { - dependsOn(":wolfskin-design:testDebugUnitTest") - systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) - systemProperty("robolectric.offline", "true") - systemProperty("roborazzi.test.record", "true") - systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) + 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 || isCI) { + implementation(files("libs/lib-decoder-ffmpeg-release.aar")) + } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b2a0c548..462dd204 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,27 +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 --keep class com.github.damontecres.wholphin.mpv.MPVLib { *; } - --keep class * extends com.google.protobuf.GeneratedMessageLite { *; } --keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { - <fields>; - <methods>; -} --dontwarn com.google.protobuf.** - -# 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; -} - -# Keep external/native functions --keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; } +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json deleted file mode 100644 index 0a7bf63d..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/10.json +++ /dev/null @@ -1,325 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 10, - "identityHash": "addc8fcbddd84a88e20fbda34849cf26", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "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, 'addc8fcbddd84a88e20fbda34849cf26')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json deleted file mode 100644 index bb9e1bd6..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/11.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 11, - "identityHash": "26bbe361923e3aac064c08c52e65264e", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "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" - ] - } - ] - } - ], - "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, '26bbe361923e3aac064c08c52e65264e')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json deleted file mode 100644 index f3b128c5..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/12.json +++ /dev/null @@ -1,434 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 12, - "identityHash": "e9bcae45448f995d1f4ebf291e45c510", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "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" - ] - } - ] - } - ], - "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, 'e9bcae45448f995d1f4ebf291e45c510')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json deleted file mode 100644 index 9c365430..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 20, - "identityHash": "dea51adcc724179afa0174d775f97480", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "PlaybackLanguageChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesId", - "columnName": "seriesId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioLanguage", - "columnName": "audioLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitleLanguage", - "columnName": "subtitleLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitlesDisabled", - "columnName": "subtitlesDisabled", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "seriesId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "ItemTrackModification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackIndex", - "columnName": "trackIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "delayMs", - "columnName": "delayMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId", - "trackIndex" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "seerr_servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_seerr_servers_url", - "unique": true, - "columnNames": [ - "url" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" - } - ] - }, - { - "tableName": "seerr_users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "authMethod", - "columnName": "authMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT" - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT" - }, - { - "fieldPath": "credential", - "columnName": "credential", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "serverId" - ] - }, - "foreignKeys": [ - { - "table": "seerr_servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "jellyfinUserRowId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dea51adcc724179afa0174d775f97480')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json deleted file mode 100644 index 715fb618..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json +++ /dev/null @@ -1,635 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 30, - "identityHash": "f9b86b73e972aa002238d43ab2c8dcc2", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "playback_effects", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "videoFilter.rotation", - "columnName": "rotation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.brightness", - "columnName": "brightness", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.contrast", - "columnName": "contrast", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.saturation", - "columnName": "saturation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.hue", - "columnName": "hue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.red", - "columnName": "red", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.green", - "columnName": "green", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blue", - "columnName": "blue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blur", - "columnName": "blur", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "itemId", - "type" - ] - } - }, - { - "tableName": "PlaybackLanguageChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesId", - "columnName": "seriesId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioLanguage", - "columnName": "audioLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitleLanguage", - "columnName": "subtitleLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitlesDisabled", - "columnName": "subtitlesDisabled", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "seriesId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "ItemTrackModification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackIndex", - "columnName": "trackIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "delayMs", - "columnName": "delayMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId", - "trackIndex" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "seerr_servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_seerr_servers_url", - "unique": true, - "columnNames": [ - "url" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" - } - ] - }, - { - "tableName": "seerr_users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "authMethod", - "columnName": "authMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT" - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT" - }, - { - "fieldPath": "credential", - "columnName": "credential", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "serverId" - ] - }, - "foreignKeys": [ - { - "table": "seerr_servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "jellyfinUserRowId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f9b86b73e972aa002238d43ab2c8dcc2')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json deleted file mode 100644 index b719e5f4..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json +++ /dev/null @@ -1,642 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 31, - "identityHash": "c6829d764ec85321ab3be9905d6c0e3a", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - }, - { - "fieldPath": "pin", - "columnName": "pin", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "order", - "columnName": "order", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "playback_effects", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "videoFilter.rotation", - "columnName": "rotation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.brightness", - "columnName": "brightness", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.contrast", - "columnName": "contrast", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.saturation", - "columnName": "saturation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.hue", - "columnName": "hue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.red", - "columnName": "red", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.green", - "columnName": "green", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blue", - "columnName": "blue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blur", - "columnName": "blur", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "itemId", - "type" - ] - } - }, - { - "tableName": "PlaybackLanguageChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesId", - "columnName": "seriesId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioLanguage", - "columnName": "audioLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitleLanguage", - "columnName": "subtitleLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitlesDisabled", - "columnName": "subtitlesDisabled", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "seriesId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "ItemTrackModification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackIndex", - "columnName": "trackIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "delayMs", - "columnName": "delayMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId", - "trackIndex" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "seerr_servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_seerr_servers_url", - "unique": true, - "columnNames": [ - "url" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" - } - ] - }, - { - "tableName": "seerr_users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "authMethod", - "columnName": "authMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT" - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT" - }, - { - "fieldPath": "credential", - "columnName": "credential", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "serverId" - ] - }, - "foreignKeys": [ - { - "table": "seerr_servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "jellyfinUserRowId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c6829d764ec85321ab3be9905d6c0e3a')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json deleted file mode 100644 index 5e58b40e..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/32.json +++ /dev/null @@ -1,659 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 32, - "identityHash": "934c38e7f4eb68c9a223ddb5c7e3e35d", - "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, 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" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "order", - "columnName": "order", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "-1" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "playback_effects", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "videoFilter.rotation", - "columnName": "rotation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.brightness", - "columnName": "brightness", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.contrast", - "columnName": "contrast", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.saturation", - "columnName": "saturation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.hue", - "columnName": "hue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.red", - "columnName": "red", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.green", - "columnName": "green", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blue", - "columnName": "blue", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "videoFilter.blur", - "columnName": "blur", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "itemId", - "type" - ] - } - }, - { - "tableName": "PlaybackLanguageChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "seriesId", - "columnName": "seriesId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioLanguage", - "columnName": "audioLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitleLanguage", - "columnName": "subtitleLanguage", - "affinity": "TEXT" - }, - { - "fieldPath": "subtitlesDisabled", - "columnName": "subtitlesDisabled", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "seriesId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "ItemTrackModification", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackIndex", - "columnName": "trackIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "delayMs", - "columnName": "delayMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId", - "trackIndex" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "seerr_servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_seerr_servers_url", - "unique": true, - "columnNames": [ - "url" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" - } - ] - }, - { - "tableName": "seerr_users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "jellyfinUserRowId", - "columnName": "jellyfinUserRowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "authMethod", - "columnName": "authMethod", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "username", - "columnName": "username", - "affinity": "TEXT" - }, - { - "fieldPath": "password", - "columnName": "password", - "affinity": "TEXT" - }, - { - "fieldPath": "credential", - "columnName": "credential", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "jellyfinUserRowId", - "serverId" - ] - }, - "foreignKeys": [ - { - "table": "seerr_servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "jellyfinUserRowId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '934c38e7f4eb68c9a223ddb5c7e3e35d')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json deleted file mode 100644 index c8d73321..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/33.json +++ /dev/null @@ -1,664 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 33, - "identityHash": "a65eec85c027fac13948a0dd6f99d3cb", - "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" - ] - } - ] - } - ], - "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, 'a65eec85c027fac13948a0dd6f99d3cb')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json deleted file mode 100644 index a5709ec2..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/34.json +++ /dev/null @@ -1,720 +0,0 @@ -{ - "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 deleted file mode 100644 index f82e6bdf..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/35.json +++ /dev/null @@ -1,741 +0,0 @@ -{ - "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/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json deleted file mode 100644 index 3e874e4e..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json +++ /dev/null @@ -1,834 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 40, - "identityHash": "b297c087485321c3ee8282ad1ff675a0", - "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" - ] - } - ] - }, - { - "tableName": "SeriesTrackChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "parentId", - "columnName": "parentId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentType", - "columnName": "parentType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "language", - "columnName": "language", - "affinity": "TEXT" - }, - { - "fieldPath": "activation", - "columnName": "activation", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackFlags", - "columnName": "trackFlags", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "codec", - "columnName": "codec", - "affinity": "TEXT" - }, - { - "fieldPath": "relativeTrackIndex", - "columnName": "relativeTrackIndex", - "affinity": "INTEGER" - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT" - }, - { - "fieldPath": "audioChannels", - "columnName": "audioChannels", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "parentId", - "type" - ] - }, - "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, 'b297c087485321c3ee8282ad1ff675a0')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json deleted file mode 100644 index 4a41a052..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json +++ /dev/null @@ -1,841 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 41, - "identityHash": "1f2fe7912f9e3ab83b77029e07488e84", - "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', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', 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'" - }, - { - "fieldPath": "config.homeSettingsSource", - "columnName": "c_homeSettingsSource", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'UNSET'" - } - ], - "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" - ] - } - ] - }, - { - "tableName": "SeriesTrackChoice", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "parentId", - "columnName": "parentId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentType", - "columnName": "parentType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT" - }, - { - "fieldPath": "language", - "columnName": "language", - "affinity": "TEXT" - }, - { - "fieldPath": "activation", - "columnName": "activation", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "trackFlags", - "columnName": "trackFlags", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "codec", - "columnName": "codec", - "affinity": "TEXT" - }, - { - "fieldPath": "relativeTrackIndex", - "columnName": "relativeTrackIndex", - "affinity": "INTEGER" - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT" - }, - { - "fieldPath": "audioChannels", - "columnName": "audioChannels", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "parentId", - "type" - ] - }, - "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, '1f2fe7912f9e3ab83b77029e07488e84')" - ] - } -} diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json deleted file mode 100644 index 7454dfe3..00000000 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json +++ /dev/null @@ -1,320 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 9, - "identityHash": "4255556f160da7bb269b114400da58da", - "entities": [ - { - "tableName": "servers", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "url", - "columnName": "url", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "serverId", - "columnName": "serverId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "accessToken", - "columnName": "accessToken", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_users_id_serverId", - "unique": true, - "columnNames": [ - "id", - "serverId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" - }, - { - "name": "index_users_id", - "unique": false, - "columnNames": [ - "id" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" - }, - { - "name": "index_users_serverId", - "unique": false, - "columnNames": [ - "serverId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" - } - ], - "foreignKeys": [ - { - "table": "servers", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "serverId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ItemPlayback", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sourceId", - "columnName": "sourceId", - "affinity": "TEXT" - }, - { - "fieldPath": "audioIndex", - "columnName": "audioIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subtitleIndex", - "columnName": "subtitleIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_ItemPlayback_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "NavDrawerPinnedItem", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - }, - { - "tableName": "LibraryDisplayInfo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sort", - "columnName": "sort", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "filter", - "columnName": "filter", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'{}'" - }, - { - "fieldPath": "viewOptions", - "columnName": "viewOptions", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId", - "itemId" - ] - }, - "indices": [ - { - "name": "index_LibraryDisplayInfo_userId_itemId", - "unique": true, - "columnNames": [ - "userId", - "itemId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "userId" - ], - "referencedColumns": [ - "rowId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4255556f160da7bb269b114400da58da')" - ] - } -} diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt deleted file mode 100644 index 09dc6745..00000000 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.github.damontecres.wholphin.test - -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.navigation3.runtime.NavBackStack -import com.github.damontecres.wholphin.MainContent -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.ScreensaverState -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@HiltAndroidTest -class InstrumentedBasicUiTests { - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule<TestActivity>() - - lateinit var screensaverService: ScreensaverService - - @Before - fun setup() { - screensaverService = mockk(relaxed = true) - every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) - } - - @OptIn(ExperimentalTestApi::class) - @Test - fun myTest() { - val navigationManager = NavigationManager() - navigationManager.backStack = NavBackStack(Destination.Home()) - // Start the app - composeTestRule.setContent { - WholphinTheme { - MainContent( - backStack = mutableListOf(SetupDestination.ServerList), - navigationManager = navigationManager, - appPreferences = mockk(relaxed = true), - backdropService = mockk(relaxed = true), - screensaverService = screensaverService, - modifier = Modifier, - ) - } - } - - composeTestRule.onNodeWithText("Add Server").assertExists() - composeTestRule.onNodeWithTag("add_server").performKeyInput { - pressKey(Key.DirectionDown) // TODO - } - composeTestRule.onNodeWithTag("add_server").performClickEnter() - - composeTestRule.onNodeWithText("Discovered Servers").assertExists() - } -} - -@OptIn(ExperimentalTestApi::class) -fun SemanticsNodeInteraction.performClickEnter() = - performKeyInput { - pressKey(Key.DirectionCenter) - } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt deleted file mode 100644 index 9aa51656..00000000 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.damontecres.wholphin.test - -import androidx.activity.ComponentActivity -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt deleted file mode 100644 index 694186ec..00000000 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.damontecres.wholphin.test - -import android.app.Application -import android.content.Context -import androidx.test.runner.AndroidJUnitRunner -import dagger.hilt.android.testing.HiltTestApplication - -class WholphinTestRunner : AndroidJUnitRunner() { - override fun newApplication( - cl: ClassLoader?, - name: String?, - context: Context?, - ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) -} diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml deleted file mode 100644 index e024ca83..00000000 --- a/app/src/appstore/AndroidManifest.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools"> - - <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" /> - <uses-permission - android:name="com.amazon.permission.media.session.voicecommandcontrol" - tools:node="remove" /> - -</manifest> diff --git a/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg deleted file mode 100644 index b9b72894..00000000 Binary files a/app/src/debug/assets/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/app/src/debug/assets/library/assets/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg deleted file mode 100644 index f7444271..00000000 Binary files a/app/src/debug/assets/library/assets/movie-a-bronx-tale-1993-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg deleted file mode 100644 index 580b22f4..00000000 Binary files a/app/src/debug/assets/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg deleted file mode 100644 index 3ce49112..00000000 Binary files a/app/src/debug/assets/library/assets/movie-absolution-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg deleted file mode 100644 index 52362b4e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-across-the-hall-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg deleted file mode 100644 index e9a03c14..00000000 Binary files a/app/src/debug/assets/library/assets/movie-addicted-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg deleted file mode 100644 index 8b65745b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-adulterers-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg deleted file mode 100644 index 8beb2f7f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg deleted file mode 100644 index 7ce6fa96..00000000 Binary files a/app/src/debug/assets/library/assets/movie-alone-with-her-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg deleted file mode 100644 index 0f41d299..00000000 Binary files a/app/src/debug/assets/library/assets/movie-american-gangster-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg deleted file mode 100644 index 9023a691..00000000 Binary files a/app/src/debug/assets/library/assets/movie-annie-hall-1977-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg deleted file mode 100644 index 030addf0..00000000 Binary files a/app/src/debug/assets/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg b/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg deleted file mode 100644 index 09cd6aa8..00000000 Binary files a/app/src/debug/assets/library/assets/movie-apocalypse-now-1979-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg deleted file mode 100644 index 62bd0afa..00000000 Binary files a/app/src/debug/assets/library/assets/movie-appleseed-alpha-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg deleted file mode 100644 index 4550bdaa..00000000 Binary files a/app/src/debug/assets/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/app/src/debug/assets/library/assets/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg deleted file mode 100644 index fe224a15..00000000 Binary files a/app/src/debug/assets/library/assets/movie-arrival-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg deleted file mode 100644 index f57f1052..00000000 Binary files a/app/src/debug/assets/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/app/src/debug/assets/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg deleted file mode 100644 index b096fff2..00000000 Binary files a/app/src/debug/assets/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg deleted file mode 100644 index 57df9298..00000000 Binary files a/app/src/debug/assets/library/assets/movie-avengers-infinity-war-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/app/src/debug/assets/library/assets/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg deleted file mode 100644 index 7f59127b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-batman-ninja-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg deleted file mode 100644 index ce5623fe..00000000 Binary files a/app/src/debug/assets/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg deleted file mode 100644 index f8115170..00000000 Binary files a/app/src/debug/assets/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg deleted file mode 100644 index dde008b5..00000000 Binary files a/app/src/debug/assets/library/assets/movie-black-adam-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg deleted file mode 100644 index 61c8860f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-black-bear-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg deleted file mode 100644 index 6fdaa3d2..00000000 Binary files a/app/src/debug/assets/library/assets/movie-black-panther-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg deleted file mode 100644 index d0219a75..00000000 Binary files a/app/src/debug/assets/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg deleted file mode 100644 index 8d50255f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-blame-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg deleted file mode 100644 index d3619fbd..00000000 Binary files a/app/src/debug/assets/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg deleted file mode 100644 index 20d2f9ee..00000000 Binary files a/app/src/debug/assets/library/assets/movie-bohemian-rhapsody-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg deleted file mode 100644 index 32fd8d4d..00000000 Binary files a/app/src/debug/assets/library/assets/movie-braveheart-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg deleted file mode 100644 index b54cc5c7..00000000 Binary files a/app/src/debug/assets/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg deleted file mode 100644 index 92fd601f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-by-any-means-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg deleted file mode 100644 index 426142ef..00000000 Binary files a/app/src/debug/assets/library/assets/movie-captain-america-civil-war-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg deleted file mode 100644 index bba96574..00000000 Binary files a/app/src/debug/assets/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg deleted file mode 100644 index c2dc5612..00000000 Binary files a/app/src/debug/assets/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg deleted file mode 100644 index b58ce600..00000000 Binary files a/app/src/debug/assets/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg deleted file mode 100644 index a6a99fee..00000000 Binary files a/app/src/debug/assets/library/assets/movie-castle-in-the-sky-1986-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg deleted file mode 100644 index 1def9414..00000000 Binary files a/app/src/debug/assets/library/assets/movie-chicago-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg deleted file mode 100644 index f26414a7..00000000 Binary files a/app/src/debug/assets/library/assets/movie-citizen-kane-1941-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg deleted file mode 100644 index f3601b9b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-coherence-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg deleted file mode 100644 index c6aecefa..00000000 Binary files a/app/src/debug/assets/library/assets/movie-conclave-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg deleted file mode 100644 index 4acfa871..00000000 Binary files a/app/src/debug/assets/library/assets/movie-crime-of-passion-1956-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg deleted file mode 100644 index 018d4f4b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-deep-water-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg deleted file mode 100644 index d93f3124..00000000 Binary files a/app/src/debug/assets/library/assets/movie-derailed-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/app/src/debug/assets/library/assets/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg deleted file mode 100644 index d3444637..00000000 Binary files a/app/src/debug/assets/library/assets/movie-dream-house-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg deleted file mode 100644 index 3b563cf9..00000000 Binary files a/app/src/debug/assets/library/assets/movie-dream-lover-1994-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg deleted file mode 100644 index 4cf7d66b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-drop-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg deleted file mode 100644 index 01a26f3b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-eastern-promises-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg b/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg deleted file mode 100644 index 45e13a32..00000000 Binary files a/app/src/debug/assets/library/assets/movie-easy-a-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg deleted file mode 100644 index 7a62df45..00000000 Binary files a/app/src/debug/assets/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg deleted file mode 100644 index c1bee971..00000000 Binary files a/app/src/debug/assets/library/assets/movie-emancipation-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg b/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg deleted file mode 100644 index 43f996bd..00000000 Binary files a/app/src/debug/assets/library/assets/movie-faces-1968-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg deleted file mode 100644 index dda9d622..00000000 Binary files a/app/src/debug/assets/library/assets/movie-fatale-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg deleted file mode 100644 index 169c466d..00000000 Binary files a/app/src/debug/assets/library/assets/movie-fighting-with-my-family-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg deleted file mode 100644 index 93ba0d71..00000000 Binary files a/app/src/debug/assets/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg deleted file mode 100644 index e69af7c0..00000000 Binary files a/app/src/debug/assets/library/assets/movie-free-state-of-jones-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg deleted file mode 100644 index 264076e2..00000000 Binary files a/app/src/debug/assets/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg deleted file mode 100644 index 58d221ba..00000000 Binary files a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg deleted file mode 100644 index ae262147..00000000 Binary files a/app/src/debug/assets/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg deleted file mode 100644 index 6a6aa651..00000000 Binary files a/app/src/debug/assets/library/assets/movie-gosford-park-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg deleted file mode 100644 index 1b71996a..00000000 Binary files a/app/src/debug/assets/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg deleted file mode 100644 index 042a6c80..00000000 Binary files a/app/src/debug/assets/library/assets/movie-howl-s-moving-castle-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg deleted file mode 100644 index e05bfdb0..00000000 Binary files a/app/src/debug/assets/library/assets/movie-in-the-heights-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg b/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg deleted file mode 100644 index 41cc1eb5..00000000 Binary files a/app/src/debug/assets/library/assets/movie-jennifer-s-body-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg deleted file mode 100644 index 80a59b1e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg deleted file mode 100644 index 4c78fee9..00000000 Binary files a/app/src/debug/assets/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg deleted file mode 100644 index 94ebe97d..00000000 Binary files a/app/src/debug/assets/library/assets/movie-locke-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg deleted file mode 100644 index 423043cf..00000000 Binary files a/app/src/debug/assets/library/assets/movie-look-who-s-back-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg deleted file mode 100644 index f3725b42..00000000 Binary files a/app/src/debug/assets/library/assets/movie-memories-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg deleted file mode 100644 index cfc0d812..00000000 Binary files a/app/src/debug/assets/library/assets/movie-mercy-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg deleted file mode 100644 index c6fa6a26..00000000 Binary files a/app/src/debug/assets/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg deleted file mode 100644 index f4c89db7..00000000 Binary files a/app/src/debug/assets/library/assets/movie-normal-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg deleted file mode 100644 index d6760468..00000000 Binary files a/app/src/debug/assets/library/assets/movie-outlaw-king-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg deleted file mode 100644 index 0b2a0d30..00000000 Binary files a/app/src/debug/assets/library/assets/movie-panic-room-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg deleted file mode 100644 index a4814472..00000000 Binary files a/app/src/debug/assets/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg deleted file mode 100644 index f548e831..00000000 Binary files a/app/src/debug/assets/library/assets/movie-ponyo-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg deleted file mode 100644 index 4a6a2bd1..00000000 Binary files a/app/src/debug/assets/library/assets/movie-porco-rosso-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg deleted file mode 100644 index 21dd9359..00000000 Binary files a/app/src/debug/assets/library/assets/movie-princess-mononoke-1997-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg deleted file mode 100644 index 411b149b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-prisoners-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg deleted file mode 100644 index cc2ef2ce..00000000 Binary files a/app/src/debug/assets/library/assets/movie-racing-stripes-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg deleted file mode 100644 index e9ab449c..00000000 Binary files a/app/src/debug/assets/library/assets/movie-reincarnated-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg deleted file mode 100644 index 6f89abc3..00000000 Binary files a/app/src/debug/assets/library/assets/movie-requiem-for-a-dream-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg deleted file mode 100644 index c79e70a1..00000000 Binary files a/app/src/debug/assets/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg deleted file mode 100644 index 33408fa0..00000000 Binary files a/app/src/debug/assets/library/assets/movie-secondhand-lions-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg deleted file mode 100644 index 0f6017ed..00000000 Binary files a/app/src/debug/assets/library/assets/movie-self-reliance-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg b/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg deleted file mode 100644 index 8ddd6e52..00000000 Binary files a/app/src/debug/assets/library/assets/movie-serpico-1973-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg deleted file mode 100644 index 6b2b79ce..00000000 Binary files a/app/src/debug/assets/library/assets/movie-shaft-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg deleted file mode 100644 index f200a28d..00000000 Binary files a/app/src/debug/assets/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg deleted file mode 100644 index 428bb38b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg deleted file mode 100644 index a5ffff1f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-sleeping-with-other-people-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg deleted file mode 100644 index be1873fd..00000000 Binary files a/app/src/debug/assets/library/assets/movie-snowpiercer-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg deleted file mode 100644 index 51fa155f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg deleted file mode 100644 index 28521997..00000000 Binary files a/app/src/debug/assets/library/assets/movie-spider-man-far-from-home-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg deleted file mode 100644 index 97fc9c05..00000000 Binary files a/app/src/debug/assets/library/assets/movie-spider-man-homecoming-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg deleted file mode 100644 index bc92a4cd..00000000 Binary files a/app/src/debug/assets/library/assets/movie-spider-man-no-way-home-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg deleted file mode 100644 index 5a79da62..00000000 Binary files a/app/src/debug/assets/library/assets/movie-spirited-away-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg deleted file mode 100644 index e85ddb90..00000000 Binary files a/app/src/debug/assets/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg b/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg deleted file mode 100644 index c410b981..00000000 Binary files a/app/src/debug/assets/library/assets/movie-taxi-driver-1976-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg deleted file mode 100644 index bb009cbf..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-alto-knights-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg deleted file mode 100644 index 4de2711f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg deleted file mode 100644 index c1aec876..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-amazing-spider-man-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg deleted file mode 100644 index 65553d47..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-animatrix-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg deleted file mode 100644 index be87067c..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-avengers-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg deleted file mode 100644 index 094df64e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg deleted file mode 100644 index d1823120..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-breakfast-club-1985-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg deleted file mode 100644 index a0379f2f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-dry-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg deleted file mode 100644 index 94111d9b..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-godfather-1972-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg deleted file mode 100644 index 1328c99e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-godfather-part-ii-1974-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg deleted file mode 100644 index abbe5e99..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-godfather-part-iii-1990-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg deleted file mode 100644 index e949fb3e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-great-gatsby-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg b/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg deleted file mode 100644 index f87e919f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-hours-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg deleted file mode 100644 index 83464d4c..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg deleted file mode 100644 index 950e9ba1..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg deleted file mode 100644 index 0d58440c..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-last-samurai-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg deleted file mode 100644 index 15f26533..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-lighthouse-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg deleted file mode 100644 index c292dce5..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-matrix-1999-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg deleted file mode 100644 index ab11f50f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-matrix-reloaded-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg deleted file mode 100644 index 4477ef1e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-matrix-resurrections-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg b/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg deleted file mode 100644 index 7236cc4e..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-matrix-revolutions-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg b/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg deleted file mode 100644 index c8582133..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-patriot-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg deleted file mode 100644 index fe50c2f8..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg deleted file mode 100644 index 72accfed..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-substance-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg deleted file mode 100644 index 882621cb..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg deleted file mode 100644 index 21976395..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-uprising-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg deleted file mode 100644 index 0894754d..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-wild-robot-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg b/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg deleted file mode 100644 index b7688c77..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-wind-rises-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg deleted file mode 100644 index 6db33db1..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg deleted file mode 100644 index 8a54bc7f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg deleted file mode 100644 index 5a4ff0ab..00000000 Binary files a/app/src/debug/assets/library/assets/movie-thelma-louise-1991-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg deleted file mode 100644 index aee092c6..00000000 Binary files a/app/src/debug/assets/library/assets/movie-triangle-of-sadness-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg b/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg deleted file mode 100644 index 362ddd82..00000000 Binary files a/app/src/debug/assets/library/assets/movie-tsotsi-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg deleted file mode 100644 index e32685f3..00000000 Binary files a/app/src/debug/assets/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg deleted file mode 100644 index 76855126..00000000 Binary files a/app/src/debug/assets/library/assets/movie-vice-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg deleted file mode 100644 index 1351cad1..00000000 Binary files a/app/src/debug/assets/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg b/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg deleted file mode 100644 index 918e424f..00000000 Binary files a/app/src/debug/assets/library/assets/movie-warriors-of-the-wind-1984-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg b/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg deleted file mode 100644 index a0d65904..00000000 Binary files a/app/src/debug/assets/library/assets/movie-we-bury-the-dead-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg deleted file mode 100644 index 3a123e80..00000000 Binary files a/app/src/debug/assets/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg deleted file mode 100644 index 96e3596e..00000000 Binary files a/app/src/debug/assets/library/assets/tv-12-monkeys-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg deleted file mode 100644 index 64ca587b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-56-days-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg deleted file mode 100644 index 3362da59..00000000 Binary files a/app/src/debug/assets/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg deleted file mode 100644 index b4d256eb..00000000 Binary files a/app/src/debug/assets/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg deleted file mode 100644 index 79f1351c..00000000 Binary files a/app/src/debug/assets/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg deleted file mode 100644 index afd371a9..00000000 Binary files a/app/src/debug/assets/library/assets/tv-absolutely-fabulous-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg deleted file mode 100644 index 207b1dd1..00000000 Binary files a/app/src/debug/assets/library/assets/tv-accel-world-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg deleted file mode 100644 index 74af010a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-accused-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg deleted file mode 100644 index 53a7ba33..00000000 Binary files a/app/src/debug/assets/library/assets/tv-adults-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg deleted file mode 100644 index 4fae4e33..00000000 Binary files a/app/src/debug/assets/library/assets/tv-after-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg deleted file mode 100644 index ea7dda99..00000000 Binary files a/app/src/debug/assets/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg deleted file mode 100644 index a29d6cb4..00000000 Binary files a/app/src/debug/assets/library/assets/tv-ahsoka-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg deleted file mode 100644 index 09ccfc33..00000000 Binary files a/app/src/debug/assets/library/assets/tv-all-american-homecoming-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg deleted file mode 100644 index 5b1666e6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg deleted file mode 100644 index b506f990..00000000 Binary files a/app/src/debug/assets/library/assets/tv-all-creatures-great-small-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/app/src/debug/assets/library/assets/tv-american-crime-story-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg deleted file mode 100644 index 1f9e239a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-american-dad-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg deleted file mode 100644 index c02c46da..00000000 Binary files a/app/src/debug/assets/library/assets/tv-american-gods-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg deleted file mode 100644 index 5f92bf05..00000000 Binary files a/app/src/debug/assets/library/assets/tv-american-housewife-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg deleted file mode 100644 index 7f10e6aa..00000000 Binary files a/app/src/debug/assets/library/assets/tv-american-primeval-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg deleted file mode 100644 index 9904c74b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-and-just-like-that-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/app/src/debug/assets/library/assets/tv-andor-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg deleted file mode 100644 index 67b766b0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-andromeda-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/app/src/debug/assets/library/assets/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg deleted file mode 100644 index e0c97321..00000000 Binary files a/app/src/debug/assets/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg deleted file mode 100644 index 6a8f9a24..00000000 Binary files a/app/src/debug/assets/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg b/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/app/src/debug/assets/library/assets/tv-arrested-development-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg deleted file mode 100644 index adee3bb7..00000000 Binary files a/app/src/debug/assets/library/assets/tv-arrow-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg deleted file mode 100644 index 5cd5f098..00000000 Binary files a/app/src/debug/assets/library/assets/tv-atlantis-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg deleted file mode 100644 index 61f459c6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-avatar-seven-havens-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg deleted file mode 100644 index 6f99263a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg deleted file mode 100644 index 9d46c2ff..00000000 Binary files a/app/src/debug/assets/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg deleted file mode 100644 index 8da6e8e2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-awkward-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg deleted file mode 100644 index a12ac33e..00000000 Binary files a/app/src/debug/assets/library/assets/tv-b-b-vol-liefde-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg deleted file mode 100644 index b00730a8..00000000 Binary files a/app/src/debug/assets/library/assets/tv-back-to-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg deleted file mode 100644 index f1c18f69..00000000 Binary files a/app/src/debug/assets/library/assets/tv-ballers-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg deleted file mode 100644 index ceef0c9d..00000000 Binary files a/app/src/debug/assets/library/assets/tv-baretta-1975-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg deleted file mode 100644 index 386d3d90..00000000 Binary files a/app/src/debug/assets/library/assets/tv-based-on-a-true-story-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg deleted file mode 100644 index a864f50c..00000000 Binary files a/app/src/debug/assets/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg deleted file mode 100644 index 7ca86592..00000000 Binary files a/app/src/debug/assets/library/assets/tv-batwoman-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg deleted file mode 100644 index fe11122d..00000000 Binary files a/app/src/debug/assets/library/assets/tv-beat-bobby-flay-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg b/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg deleted file mode 100644 index fe264c76..00000000 Binary files a/app/src/debug/assets/library/assets/tv-benson-1979-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg deleted file mode 100644 index 93e1e86a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-big-boys-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg deleted file mode 100644 index ef024946..00000000 Binary files a/app/src/debug/assets/library/assets/tv-big-city-greens-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg deleted file mode 100644 index 4f535fec..00000000 Binary files a/app/src/debug/assets/library/assets/tv-big-mouth-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg deleted file mode 100644 index 97c885ad..00000000 Binary files a/app/src/debug/assets/library/assets/tv-black-ish-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg deleted file mode 100644 index 733f5cbc..00000000 Binary files a/app/src/debug/assets/library/assets/tv-black-lightning-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg deleted file mode 100644 index 3ff9aeed..00000000 Binary files a/app/src/debug/assets/library/assets/tv-black-rabbit-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg deleted file mode 100644 index b233e033..00000000 Binary files a/app/src/debug/assets/library/assets/tv-bleach-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg deleted file mode 100644 index 5e193ab2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-blue-planet-ii-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg deleted file mode 100644 index 73b77df2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-brassic-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg deleted file mode 100644 index c4f7217c..00000000 Binary files a/app/src/debug/assets/library/assets/tv-breaking-bad-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/app/src/debug/assets/library/assets/tv-breeders-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/app/src/debug/assets/library/assets/tv-californication-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg deleted file mode 100644 index 466a1a7b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-call-the-midwife-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg deleted file mode 100644 index a3779772..00000000 Binary files a/app/src/debug/assets/library/assets/tv-chainsaw-man-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg deleted file mode 100644 index 7664f4b0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-chernobyl-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg b/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg deleted file mode 100644 index e75072ae..00000000 Binary files a/app/src/debug/assets/library/assets/tv-community-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg deleted file mode 100644 index 8d338532..00000000 Binary files a/app/src/debug/assets/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg deleted file mode 100644 index f42cec16..00000000 Binary files a/app/src/debug/assets/library/assets/tv-dan-da-dan-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg deleted file mode 100644 index f5546d88..00000000 Binary files a/app/src/debug/assets/library/assets/tv-dead-end-job-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg deleted file mode 100644 index 56a8cd79..00000000 Binary files a/app/src/debug/assets/library/assets/tv-derry-girls-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg deleted file mode 100644 index 0c4c38bc..00000000 Binary files a/app/src/debug/assets/library/assets/tv-desperate-housewives-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg deleted file mode 100644 index ac0b5900..00000000 Binary files a/app/src/debug/assets/library/assets/tv-dopesick-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg deleted file mode 100644 index 0783ed8c..00000000 Binary files a/app/src/debug/assets/library/assets/tv-dr-stone-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg deleted file mode 100644 index eef2a4f3..00000000 Binary files a/app/src/debug/assets/library/assets/tv-dune-prophecy-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg deleted file mode 100644 index 6f06f2c9..00000000 Binary files a/app/src/debug/assets/library/assets/tv-east-of-eden-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg deleted file mode 100644 index f078bc89..00000000 Binary files a/app/src/debug/assets/library/assets/tv-etoile-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg deleted file mode 100644 index 5e87a07d..00000000 Binary files a/app/src/debug/assets/library/assets/tv-fallout-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg b/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg deleted file mode 100644 index 53b406da..00000000 Binary files a/app/src/debug/assets/library/assets/tv-fawlty-towers-1975-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg deleted file mode 100644 index 9ad599b3..00000000 Binary files a/app/src/debug/assets/library/assets/tv-firefly-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg deleted file mode 100644 index bd3ccea5..00000000 Binary files a/app/src/debug/assets/library/assets/tv-fleabag-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg deleted file mode 100644 index cb94104e..00000000 Binary files a/app/src/debug/assets/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg deleted file mode 100644 index 935e4944..00000000 Binary files a/app/src/debug/assets/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/app/src/debug/assets/library/assets/tv-gilmore-girls-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg deleted file mode 100644 index 1fca7bf6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-ginny-georgia-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg deleted file mode 100644 index 41c051e0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-gravity-falls-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg deleted file mode 100644 index 2427108b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-hellcats-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/app/src/debug/assets/library/assets/tv-high-potential-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg deleted file mode 100644 index e80c7999..00000000 Binary files a/app/src/debug/assets/library/assets/tv-how-to-change-your-mind-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg deleted file mode 100644 index eb5fc487..00000000 Binary files a/app/src/debug/assets/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg deleted file mode 100644 index f3a21b66..00000000 Binary files a/app/src/debug/assets/library/assets/tv-interrogation-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg deleted file mode 100644 index c278e37e..00000000 Binary files a/app/src/debug/assets/library/assets/tv-invincible-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg deleted file mode 100644 index 652923ee..00000000 Binary files a/app/src/debug/assets/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg deleted file mode 100644 index 45f5ff22..00000000 Binary files a/app/src/debug/assets/library/assets/tv-jujutsu-kaisen-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg deleted file mode 100644 index 062496fa..00000000 Binary files a/app/src/debug/assets/library/assets/tv-kaiju-no-8-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg deleted file mode 100644 index 3d348798..00000000 Binary files a/app/src/debug/assets/library/assets/tv-landman-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg deleted file mode 100644 index a9789909..00000000 Binary files a/app/src/debug/assets/library/assets/tv-last-samurai-standing-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg deleted file mode 100644 index b1229ed8..00000000 Binary files a/app/src/debug/assets/library/assets/tv-lord-of-the-flies-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg deleted file mode 100644 index 08449aa9..00000000 Binary files a/app/src/debug/assets/library/assets/tv-love-death-robots-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg deleted file mode 100644 index 62ccf357..00000000 Binary files a/app/src/debug/assets/library/assets/tv-love-is-blind-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg deleted file mode 100644 index a1985217..00000000 Binary files a/app/src/debug/assets/library/assets/tv-maniac-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg deleted file mode 100644 index abe1b2e2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg deleted file mode 100644 index a8797fe5..00000000 Binary files a/app/src/debug/assets/library/assets/tv-minx-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg deleted file mode 100644 index 925d5c49..00000000 Binary files a/app/src/debug/assets/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/app/src/debug/assets/library/assets/tv-my-hero-academia-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg deleted file mode 100644 index f86cd698..00000000 Binary files a/app/src/debug/assets/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg deleted file mode 100644 index e7206b88..00000000 Binary files a/app/src/debug/assets/library/assets/tv-norsemen-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/app/src/debug/assets/library/assets/tv-offspring-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg deleted file mode 100644 index 286e6260..00000000 Binary files a/app/src/debug/assets/library/assets/tv-one-piece-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg deleted file mode 100644 index a6b147a4..00000000 Binary files a/app/src/debug/assets/library/assets/tv-only-murders-in-the-building-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg deleted file mode 100644 index 34b1ed84..00000000 Binary files a/app/src/debug/assets/library/assets/tv-over-the-garden-wall-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg deleted file mode 100644 index 25ceea88..00000000 Binary files a/app/src/debug/assets/library/assets/tv-peaky-blinders-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg deleted file mode 100644 index 1ea4711b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-pluribus-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg deleted file mode 100644 index 97d481d6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-record-of-ragnarok-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/app/src/debug/assets/library/assets/tv-rick-and-morty-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg deleted file mode 100644 index de0b32dd..00000000 Binary files a/app/src/debug/assets/library/assets/tv-ripley-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg deleted file mode 100644 index 296ac67a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-rivals-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg deleted file mode 100644 index b698f8a6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-rooster-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg deleted file mode 100644 index ed9358b3..00000000 Binary files a/app/src/debug/assets/library/assets/tv-sakamoto-days-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg deleted file mode 100644 index cf50fc09..00000000 Binary files a/app/src/debug/assets/library/assets/tv-say-nothing-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg deleted file mode 100644 index 1b6e67f6..00000000 Binary files a/app/src/debug/assets/library/assets/tv-secret-level-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg deleted file mode 100644 index 2fb1c815..00000000 Binary files a/app/src/debug/assets/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg deleted file mode 100644 index 5a29d6f9..00000000 Binary files a/app/src/debug/assets/library/assets/tv-severance-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/library/assets/tv-sex-and-the-city-1998-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg deleted file mode 100644 index b493bc7b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-silo-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg b/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg deleted file mode 100644 index 559d6bd1..00000000 Binary files a/app/src/debug/assets/library/assets/tv-six-feet-under-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg deleted file mode 100644 index 2540dad7..00000000 Binary files a/app/src/debug/assets/library/assets/tv-skymed-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg deleted file mode 100644 index a2e91496..00000000 Binary files a/app/src/debug/assets/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg b/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/app/src/debug/assets/library/assets/tv-south-park-1997-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg deleted file mode 100644 index fc326930..00000000 Binary files a/app/src/debug/assets/library/assets/tv-spy-x-family-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg deleted file mode 100644 index 4d254cb0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-100-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg deleted file mode 100644 index 9beacde2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-afterparty-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg deleted file mode 100644 index 7e4e7be2..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-baby-sitters-club-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-bear-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg deleted file mode 100644 index c78c5cf0..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-beast-in-me-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg deleted file mode 100644 index f663eb8b..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-bold-type-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg deleted file mode 100644 index f67ff16f..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-boys-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg deleted file mode 100644 index a4039011..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-capture-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg deleted file mode 100644 index 2eb48b62..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-diplomat-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-durrells-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg deleted file mode 100644 index f0332826..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-final-problem-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg deleted file mode 100644 index fd252088..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-four-seasons-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg deleted file mode 100644 index 7bb38abd..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-get-down-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg deleted file mode 100644 index 034273eb..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-hunting-wives-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg deleted file mode 100644 index 7da462da..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-last-of-us-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg deleted file mode 100644 index a7086d5e..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-legend-of-korra-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg deleted file mode 100644 index dc387fcd..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg deleted file mode 100644 index 71407f36..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg deleted file mode 100644 index 7ab2dbf5..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-night-manager-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg b/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg deleted file mode 100644 index ad1ec8ff..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-pitt-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg deleted file mode 100644 index 5324dc75..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-tatami-galaxy-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg b/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg deleted file mode 100644 index 715d0c80..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-walk-in-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-white-lotus-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg deleted file mode 100644 index f5eb8f59..00000000 Binary files a/app/src/debug/assets/library/assets/tv-the-witcher-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg deleted file mode 100644 index 8611eab4..00000000 Binary files a/app/src/debug/assets/library/assets/tv-undercover-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg deleted file mode 100644 index 547d3b7a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-upload-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg b/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg deleted file mode 100644 index 7d0ba17a..00000000 Binary files a/app/src/debug/assets/library/assets/tv-vinland-saga-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg deleted file mode 100644 index 8c02a0d7..00000000 Binary files a/app/src/debug/assets/library/assets/tv-widow-s-bay-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg deleted file mode 100644 index c7e16a57..00000000 Binary files a/app/src/debug/assets/library/assets/tv-yellowjackets-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg b/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/app/src/debug/assets/library/assets/tv-yu-yu-hakusho-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg deleted file mode 100644 index f5b2f94c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-announcement-2022-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg deleted file mode 100644 index f2519b04..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg deleted file mode 100644 index 77febd79..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-1-2025-s1e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-episode-2-2016-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-erickerhead-2026-s9e6-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-girls-interrupted-2007-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-i-heart-ny-2002-s4e18-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-new-day-2021-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg deleted file mode 100644 index 9d95ba77..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-no-honeymoon-2020-s1e8-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg deleted file mode 100644 index ecb7e8cd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-notapusy-2005-s3e4-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-one-of-us-2024-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg deleted file mode 100644 index 23a242da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-remembrance-day-2026-s2e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg deleted file mode 100644 index cd0aec39..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg deleted file mode 100644 index 84944d39..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg deleted file mode 100644 index bc22e11a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg deleted file mode 100644 index ba65ca2d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-three-monsters-1992-s1e6-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-tomorrow-2024-s3e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg deleted file mode 100644 index 13f0f7b3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg deleted file mode 100644 index f0d6f752..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg deleted file mode 100644 index b9b72894..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg deleted file mode 100644 index d93357e5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg deleted file mode 100644 index 15c11ff7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg deleted file mode 100644 index f7444271..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-a-bronx-tale-1993-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg deleted file mode 100644 index b386b136..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg deleted file mode 100644 index 580b22f4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-a-silent-voice-the-movie-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg deleted file mode 100644 index eb28037c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg deleted file mode 100644 index 3ce49112..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-absolution-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg deleted file mode 100644 index 6c1ee8ac..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg deleted file mode 100644 index 52362b4e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-across-the-hall-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg deleted file mode 100644 index 5c016976..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg deleted file mode 100644 index e9a03c14..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-addicted-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg deleted file mode 100644 index 331391ed..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg deleted file mode 100644 index 8b65745b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-adulterers-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg deleted file mode 100644 index ac4b828a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg deleted file mode 100644 index 8beb2f7f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-all-the-money-in-the-world-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg deleted file mode 100644 index ddf8f1dc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg deleted file mode 100644 index 7ce6fa96..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-alone-with-her-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg deleted file mode 100644 index cb5c5de7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg deleted file mode 100644 index 0f41d299..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-american-gangster-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg deleted file mode 100644 index 16d0b27d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg deleted file mode 100644 index 9023a691..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-annie-hall-1977-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg deleted file mode 100644 index 2a99846d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg deleted file mode 100644 index 030addf0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg deleted file mode 100644 index 93292da3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg deleted file mode 100644 index 09cd6aa8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-apocalypse-now-1979-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg deleted file mode 100644 index 82579b86..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg deleted file mode 100644 index 62bd0afa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-appleseed-alpha-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg deleted file mode 100644 index 6693cb6d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg deleted file mode 100644 index 4550bdaa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg deleted file mode 100644 index 1794c99d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg deleted file mode 100644 index 605724e7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg deleted file mode 100644 index fe224a15..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-arrival-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg deleted file mode 100644 index d3b12fe0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg deleted file mode 100644 index f57f1052..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg deleted file mode 100644 index f739d620..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg deleted file mode 100644 index 8163931a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg deleted file mode 100644 index b096fff2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg deleted file mode 100644 index 90dc4672..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg deleted file mode 100644 index 57df9298..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-avengers-infinity-war-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg deleted file mode 100644 index 6ddd7f59..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg deleted file mode 100644 index 9b538d9f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg deleted file mode 100644 index 7f59127b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-ninja-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg deleted file mode 100644 index 9d385801..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg deleted file mode 100644 index ce5623fe..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg deleted file mode 100644 index b4fa63b9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg deleted file mode 100644 index f8115170..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg deleted file mode 100644 index 3232fb22..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg deleted file mode 100644 index dde008b5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-adam-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg deleted file mode 100644 index 4b0b262f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg deleted file mode 100644 index 61c8860f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-bear-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg deleted file mode 100644 index f0b8de8a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg deleted file mode 100644 index 6fdaa3d2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg deleted file mode 100644 index ac7ad532..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg deleted file mode 100644 index d0219a75..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-black-panther-wakanda-forever-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg deleted file mode 100644 index f39cf5ce..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg deleted file mode 100644 index 8d50255f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-blame-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg deleted file mode 100644 index f426d0ce..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg deleted file mode 100644 index d3619fbd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg deleted file mode 100644 index b6b5a7cd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg deleted file mode 100644 index 20d2f9ee..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-bohemian-rhapsody-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg deleted file mode 100644 index c1c84c59..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg deleted file mode 100644 index 32fd8d4d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-braveheart-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg deleted file mode 100644 index e49fe1d5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg deleted file mode 100644 index b54cc5c7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-butterfly-on-a-wheel-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg deleted file mode 100644 index e536359f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg deleted file mode 100644 index 92fd601f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-by-any-means-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg deleted file mode 100644 index 5700c4e7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg deleted file mode 100644 index 426142ef..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-civil-war-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg deleted file mode 100644 index 411ceebe..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg deleted file mode 100644 index bba96574..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-first-avenger-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg deleted file mode 100644 index 5661fcff..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg deleted file mode 100644 index c2dc5612..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg deleted file mode 100644 index 988e20d5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg deleted file mode 100644 index b58ce600..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-careful-what-you-wish-for-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg deleted file mode 100644 index f785c549..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg deleted file mode 100644 index a6a99fee..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-castle-in-the-sky-1986-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg deleted file mode 100644 index 6b3d02a3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg deleted file mode 100644 index 1def9414..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-chicago-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg deleted file mode 100644 index d41a10ef..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg deleted file mode 100644 index f26414a7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-citizen-kane-1941-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg deleted file mode 100644 index 37da64f9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg deleted file mode 100644 index f3601b9b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-coherence-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg deleted file mode 100644 index db0a7fcd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg deleted file mode 100644 index c6aecefa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-conclave-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg deleted file mode 100644 index 6c70a452..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg deleted file mode 100644 index 4acfa871..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-crime-of-passion-1956-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg deleted file mode 100644 index bec98992..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg deleted file mode 100644 index 018d4f4b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-deep-water-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg deleted file mode 100644 index e958449b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg deleted file mode 100644 index d93f3124..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-derailed-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg deleted file mode 100644 index 754e713f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg deleted file mode 100644 index 29ecc103..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg deleted file mode 100644 index d3444637..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-house-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg deleted file mode 100644 index 7f5a31ac..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg deleted file mode 100644 index 3b563cf9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-dream-lover-1994-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg deleted file mode 100644 index ee642530..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg deleted file mode 100644 index 4cf7d66b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-drop-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg deleted file mode 100644 index 9096a0fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg deleted file mode 100644 index 01a26f3b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-eastern-promises-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg deleted file mode 100644 index c27fa57b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg deleted file mode 100644 index 45e13a32..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-easy-a-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg deleted file mode 100644 index 8edc02a6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg deleted file mode 100644 index 7a62df45..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg deleted file mode 100644 index ac0777c7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg deleted file mode 100644 index c1bee971..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-emancipation-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg deleted file mode 100644 index 60273dd9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg deleted file mode 100644 index 43f996bd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-faces-1968-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg deleted file mode 100644 index b13c33c6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg deleted file mode 100644 index dda9d622..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-fatale-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg deleted file mode 100644 index 46835996..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg deleted file mode 100644 index 169c466d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-fighting-with-my-family-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg deleted file mode 100644 index 3735ee63..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg deleted file mode 100644 index 93ba0d71..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg deleted file mode 100644 index a7e5ddff..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg deleted file mode 100644 index e69af7c0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-free-state-of-jones-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg deleted file mode 100644 index 32a28bd7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg deleted file mode 100644 index 264076e2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-from-up-on-poppy-hill-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg deleted file mode 100644 index 716b8a87..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg deleted file mode 100644 index 58d221ba..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg deleted file mode 100644 index 4a9931ed..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg deleted file mode 100644 index ae262147..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg deleted file mode 100644 index 42c4cea6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg deleted file mode 100644 index 6a6aa651..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-gosford-park-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg deleted file mode 100644 index 0e1add06..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg deleted file mode 100644 index 1b71996a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-grave-of-the-fireflies-1988-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg deleted file mode 100644 index 0aeef2ba..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg deleted file mode 100644 index 042a6c80..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-howl-s-moving-castle-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg deleted file mode 100644 index 7cd4ee24..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg deleted file mode 100644 index e05bfdb0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-in-the-heights-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg deleted file mode 100644 index 032771a0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg deleted file mode 100644 index 41cc1eb5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-jennifer-s-body-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg deleted file mode 100644 index 2a108f0c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg deleted file mode 100644 index 80a59b1e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-kiki-s-delivery-service-1989-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg deleted file mode 100644 index 38203b77..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg deleted file mode 100644 index 4c78fee9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg deleted file mode 100644 index a24e64ab..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg deleted file mode 100644 index 94ebe97d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-locke-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg deleted file mode 100644 index a064686f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg deleted file mode 100644 index 423043cf..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-look-who-s-back-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg deleted file mode 100644 index 2fcaeaad..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg deleted file mode 100644 index f3725b42..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-memories-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg deleted file mode 100644 index 45591cc8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg deleted file mode 100644 index cfc0d812..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-mercy-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg deleted file mode 100644 index da38be48..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg deleted file mode 100644 index c6fa6a26..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg deleted file mode 100644 index 51711864..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg deleted file mode 100644 index f4c89db7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-normal-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg deleted file mode 100644 index 54ac5a2a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg deleted file mode 100644 index d6760468..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-outlaw-king-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg deleted file mode 100644 index ec877751..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg deleted file mode 100644 index 0b2a0d30..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-panic-room-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg deleted file mode 100644 index e9d7155a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg deleted file mode 100644 index a4814472..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg deleted file mode 100644 index 8aa4ecbd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg deleted file mode 100644 index f548e831..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-ponyo-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg deleted file mode 100644 index 63d8112e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg deleted file mode 100644 index 4a6a2bd1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-porco-rosso-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg deleted file mode 100644 index 9486d2cf..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg deleted file mode 100644 index 21dd9359..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-princess-mononoke-1997-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg deleted file mode 100644 index 724008c5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg deleted file mode 100644 index 411b149b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-prisoners-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg deleted file mode 100644 index 1d332cec..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg deleted file mode 100644 index cc2ef2ce..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-racing-stripes-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg deleted file mode 100644 index 326e8ebc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg deleted file mode 100644 index e9ab449c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-reincarnated-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg deleted file mode 100644 index 21842ef2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg deleted file mode 100644 index 6f89abc3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-requiem-for-a-dream-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg deleted file mode 100644 index ff0c14c0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg deleted file mode 100644 index c79e70a1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg deleted file mode 100644 index 7ea43937..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg deleted file mode 100644 index 33408fa0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-secondhand-lions-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg deleted file mode 100644 index 3cc0d123..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg deleted file mode 100644 index 0f6017ed..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-self-reliance-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg deleted file mode 100644 index c9649485..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg deleted file mode 100644 index 8ddd6e52..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-serpico-1973-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg deleted file mode 100644 index 13f14f93..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg deleted file mode 100644 index 6b2b79ce..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-shaft-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg deleted file mode 100644 index 6e98e242..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg deleted file mode 100644 index f200a28d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg deleted file mode 100644 index d04685fe..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg deleted file mode 100644 index 428bb38b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg deleted file mode 100644 index df5bb046..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg deleted file mode 100644 index a5ffff1f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-sleeping-with-other-people-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg deleted file mode 100644 index c1281a9c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg deleted file mode 100644 index be1873fd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-snowpiercer-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg deleted file mode 100644 index 2fdaadb4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg deleted file mode 100644 index 51fa155f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-brand-new-day-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg deleted file mode 100644 index d09f655a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg deleted file mode 100644 index 28521997..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-far-from-home-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg deleted file mode 100644 index 6088fadf..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg deleted file mode 100644 index 97fc9c05..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-homecoming-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg deleted file mode 100644 index a76afec9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg deleted file mode 100644 index bc92a4cd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spider-man-no-way-home-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg deleted file mode 100644 index af91c123..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg deleted file mode 100644 index 5a79da62..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-spirited-away-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg deleted file mode 100644 index f6675060..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg deleted file mode 100644 index e85ddb90..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg deleted file mode 100644 index 7946ea40..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg deleted file mode 100644 index c410b981..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-taxi-driver-1976-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg deleted file mode 100644 index ea619c14..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg deleted file mode 100644 index bb009cbf..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-alto-knights-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg deleted file mode 100644 index 7c5878b7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg deleted file mode 100644 index 4de2711f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg deleted file mode 100644 index 75ae9b64..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg deleted file mode 100644 index c1aec876..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-amazing-spider-man-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg deleted file mode 100644 index 7d67ccb0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg deleted file mode 100644 index 65553d47..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-animatrix-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg deleted file mode 100644 index 83636f25..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg deleted file mode 100644 index be87067c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-avengers-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg deleted file mode 100644 index a19dc77b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg deleted file mode 100644 index 094df64e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-boy-and-the-heron-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg deleted file mode 100644 index 9f979cd8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg deleted file mode 100644 index d1823120..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-breakfast-club-1985-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg deleted file mode 100644 index 2f016498..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg deleted file mode 100644 index a0379f2f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-dry-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg deleted file mode 100644 index 6e920467..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg deleted file mode 100644 index 8fbb8f2a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg deleted file mode 100644 index 94111d9b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-1972-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg deleted file mode 100644 index 6dbff534..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg deleted file mode 100644 index 1328c99e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-ii-1974-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg deleted file mode 100644 index d2080487..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg deleted file mode 100644 index abbe5e99..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-godfather-part-iii-1990-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg deleted file mode 100644 index 7a4f8a32..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg deleted file mode 100644 index e949fb3e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-great-gatsby-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg deleted file mode 100644 index 507a78c4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg deleted file mode 100644 index f87e919f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-hours-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg deleted file mode 100644 index dd755ee8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg deleted file mode 100644 index 83464d4c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg deleted file mode 100644 index 3256d38d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg deleted file mode 100644 index 950e9ba1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-of-the-mohicans-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg deleted file mode 100644 index 509af2d6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg deleted file mode 100644 index 0d58440c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-last-samurai-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg deleted file mode 100644 index 862c064a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg deleted file mode 100644 index 15f26533..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-lighthouse-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg deleted file mode 100644 index c3bf8f52..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg deleted file mode 100644 index c292dce5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-1999-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg deleted file mode 100644 index dcbf7b96..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg deleted file mode 100644 index ab11f50f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-reloaded-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg deleted file mode 100644 index 99d85a40..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg deleted file mode 100644 index 4477ef1e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-resurrections-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg deleted file mode 100644 index 2cc02692..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg deleted file mode 100644 index 7236cc4e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-matrix-revolutions-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg deleted file mode 100644 index ce7ce498..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg deleted file mode 100644 index c8582133..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-patriot-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg deleted file mode 100644 index 698f4ad2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg deleted file mode 100644 index fe50c2f8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg deleted file mode 100644 index 47a8b372..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg deleted file mode 100644 index 72accfed..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-substance-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg deleted file mode 100644 index b9157b91..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg deleted file mode 100644 index 882621cb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg deleted file mode 100644 index 638eab3d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg deleted file mode 100644 index 21976395..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-uprising-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg deleted file mode 100644 index bb48460b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg deleted file mode 100644 index 0894754d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wild-robot-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg deleted file mode 100644 index 2af40e28..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg deleted file mode 100644 index b7688c77..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-wind-rises-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg deleted file mode 100644 index cb5bfb28..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg deleted file mode 100644 index 6db33db1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg deleted file mode 100644 index aad8ea2c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg deleted file mode 100644 index 8a54bc7f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg deleted file mode 100644 index a35327a9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg deleted file mode 100644 index 5a4ff0ab..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-thelma-louise-1991-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg deleted file mode 100644 index 7c9b445d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg deleted file mode 100644 index aee092c6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-triangle-of-sadness-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg deleted file mode 100644 index e119de53..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg deleted file mode 100644 index 362ddd82..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-tsotsi-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg deleted file mode 100644 index f456c01d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg deleted file mode 100644 index e32685f3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg deleted file mode 100644 index 2918db3a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg deleted file mode 100644 index 76855126..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-vice-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg deleted file mode 100644 index 20eaa159..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg deleted file mode 100644 index 1351cad1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-vicky-cristina-barcelona-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg deleted file mode 100644 index 1fb22b96..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg deleted file mode 100644 index 918e424f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-warriors-of-the-wind-1984-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg deleted file mode 100644 index 4a24720d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg deleted file mode 100644 index a0d65904..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-we-bury-the-dead-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg deleted file mode 100644 index 2f186030..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg deleted file mode 100644 index 3a123e80..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg deleted file mode 100644 index 3d2825f5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg deleted file mode 100644 index 96e3596e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-12-monkeys-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg deleted file mode 100644 index dcda667a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg deleted file mode 100644 index 64ca587b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-56-days-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg deleted file mode 100644 index 24f66f2d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg deleted file mode 100644 index 3362da59..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg deleted file mode 100644 index b18891fd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg deleted file mode 100644 index b4d256eb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg deleted file mode 100644 index ce12fc71..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg deleted file mode 100644 index 79f1351c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg deleted file mode 100644 index 2df9722d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg deleted file mode 100644 index afd371a9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-absolutely-fabulous-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg deleted file mode 100644 index 1f5f6bdc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg deleted file mode 100644 index 207b1dd1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-accel-world-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg deleted file mode 100644 index d41c6397..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg deleted file mode 100644 index 74af010a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-accused-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg deleted file mode 100644 index 9cc0ffe2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg deleted file mode 100644 index 53a7ba33..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-adults-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg deleted file mode 100644 index eaf9a4fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg deleted file mode 100644 index 4fae4e33..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-after-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg deleted file mode 100644 index 0c52f2e6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg deleted file mode 100644 index ea7dda99..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-agatha-christie-s-marple-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg deleted file mode 100644 index 7343fb32..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg deleted file mode 100644 index a29d6cb4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ahsoka-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg deleted file mode 100644 index 09fbb9dc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg deleted file mode 100644 index 09ccfc33..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-american-homecoming-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg deleted file mode 100644 index 909ae1ba..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg deleted file mode 100644 index 5b1666e6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-and-small-1978-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg deleted file mode 100644 index d2bcc88d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg deleted file mode 100644 index b506f990..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-all-creatures-great-small-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg deleted file mode 100644 index 84944d39..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-crime-story-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg deleted file mode 100644 index 75a1948b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg deleted file mode 100644 index 1f9e239a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-dad-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg deleted file mode 100644 index 4817c690..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg deleted file mode 100644 index c02c46da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-gods-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg deleted file mode 100644 index bf758ae0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg deleted file mode 100644 index 5f92bf05..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-housewife-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg deleted file mode 100644 index c3bfe5e1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg deleted file mode 100644 index 7f10e6aa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-american-primeval-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg deleted file mode 100644 index 59c981e5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg deleted file mode 100644 index 9904c74b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-and-just-like-that-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-andor-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg deleted file mode 100644 index 199249d7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg deleted file mode 100644 index 67b766b0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-andromeda-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg deleted file mode 100644 index 15e325c9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg deleted file mode 100644 index d0e37959..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg deleted file mode 100644 index e0c97321..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-aqua-teen-hunger-force-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg deleted file mode 100644 index 9300e20f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg deleted file mode 100644 index 6a8f9a24..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg deleted file mode 100644 index ecb7e8cd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arrested-development-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg deleted file mode 100644 index b32f5aca..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg deleted file mode 100644 index adee3bb7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-arrow-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg deleted file mode 100644 index 39ecc0d6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg deleted file mode 100644 index 5cd5f098..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-atlantis-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg deleted file mode 100644 index dacfd59e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg deleted file mode 100644 index 61f459c6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-seven-havens-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg deleted file mode 100644 index f60120da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg deleted file mode 100644 index 6f99263a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg deleted file mode 100644 index ad740218..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg deleted file mode 100644 index 9d46c2ff..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-avatar-the-last-airbender-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg deleted file mode 100644 index 1ca5e56c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg deleted file mode 100644 index 8da6e8e2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-awkward-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg deleted file mode 100644 index 57ece15e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg deleted file mode 100644 index a12ac33e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-b-b-vol-liefde-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg deleted file mode 100644 index 7ffef681..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg deleted file mode 100644 index b00730a8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-back-to-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg deleted file mode 100644 index c4685e84..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg deleted file mode 100644 index f1c18f69..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ballers-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg deleted file mode 100644 index 4d61dfbe..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg deleted file mode 100644 index ceef0c9d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-baretta-1975-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg deleted file mode 100644 index b59c0e58..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg deleted file mode 100644 index 386d3d90..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-based-on-a-true-story-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg deleted file mode 100644 index 69e567cb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg deleted file mode 100644 index a864f50c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg deleted file mode 100644 index d04a1a20..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg deleted file mode 100644 index 7ca86592..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-batwoman-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg deleted file mode 100644 index e6c36ddd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg deleted file mode 100644 index fe11122d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-beat-bobby-flay-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg deleted file mode 100644 index 32bd63a9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg deleted file mode 100644 index fe264c76..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-benson-1979-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg deleted file mode 100644 index c7c91e2d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg deleted file mode 100644 index 93e1e86a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-boys-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg deleted file mode 100644 index 381774e7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg deleted file mode 100644 index ef024946..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-city-greens-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg deleted file mode 100644 index 760e0262..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg deleted file mode 100644 index 4f535fec..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-big-mouth-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg deleted file mode 100644 index 967fb621..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg deleted file mode 100644 index 97c885ad..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-ish-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg deleted file mode 100644 index 3a0854c9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg deleted file mode 100644 index 733f5cbc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-lightning-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg deleted file mode 100644 index 48df9d46..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg deleted file mode 100644 index 3ff9aeed..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-black-rabbit-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg deleted file mode 100644 index ef8eaaa3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg deleted file mode 100644 index b233e033..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-bleach-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg deleted file mode 100644 index 8175e568..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg deleted file mode 100644 index 5e193ab2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-blue-planet-ii-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg deleted file mode 100644 index 86d3d806..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg deleted file mode 100644 index 73b77df2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-brassic-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg deleted file mode 100644 index e4ac4ae3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg deleted file mode 100644 index c4f7217c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-breaking-bad-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg deleted file mode 100644 index 9d95ba77..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-breeders-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-californication-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg deleted file mode 100644 index 1ee28b8d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg deleted file mode 100644 index 466a1a7b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-call-the-midwife-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg deleted file mode 100644 index f1fbee3e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg deleted file mode 100644 index a3779772..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-chainsaw-man-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg deleted file mode 100644 index 818a6665..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg deleted file mode 100644 index 7664f4b0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-chernobyl-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg deleted file mode 100644 index 8deb8d5c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg deleted file mode 100644 index e75072ae..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-community-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg deleted file mode 100644 index 2920cf5a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg deleted file mode 100644 index 8d338532..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-cyberpunk-edgerunners-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg deleted file mode 100644 index 0cbc4b07..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg deleted file mode 100644 index f42cec16..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dan-da-dan-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg deleted file mode 100644 index 3525c2fc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg deleted file mode 100644 index f5546d88..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dead-end-job-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg deleted file mode 100644 index 76fae98c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg deleted file mode 100644 index 56a8cd79..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-derry-girls-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg deleted file mode 100644 index 0c4c38bc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-desperate-housewives-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg deleted file mode 100644 index 8d9f2727..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg deleted file mode 100644 index ac0b5900..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dopesick-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg deleted file mode 100644 index 5ac396b6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg deleted file mode 100644 index 0783ed8c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dr-stone-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg deleted file mode 100644 index 5b940b9f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg deleted file mode 100644 index eef2a4f3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-dune-prophecy-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg deleted file mode 100644 index 89dfafbf..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg deleted file mode 100644 index 6f06f2c9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-east-of-eden-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg deleted file mode 100644 index a8e9576c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg deleted file mode 100644 index f078bc89..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-etoile-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg deleted file mode 100644 index c9d6a190..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg deleted file mode 100644 index 5e87a07d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fallout-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg deleted file mode 100644 index 2c9c258f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg deleted file mode 100644 index 53b406da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fawlty-towers-1975-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg deleted file mode 100644 index fa3b7604..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg deleted file mode 100644 index 9ad599b3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-firefly-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg deleted file mode 100644 index e5a83462..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg deleted file mode 100644 index bd3ccea5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fleabag-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg deleted file mode 100644 index a42a2c94..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg deleted file mode 100644 index cb94104e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg deleted file mode 100644 index 91ce4909..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg deleted file mode 100644 index 935e4944..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-gilmore-girls-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg deleted file mode 100644 index 1271d577..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg deleted file mode 100644 index 1fca7bf6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ginny-georgia-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg deleted file mode 100644 index b9ef40fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg deleted file mode 100644 index 41c051e0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-gravity-falls-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg deleted file mode 100644 index 38e856b0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg deleted file mode 100644 index 2427108b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-hellcats-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-high-potential-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg deleted file mode 100644 index 9e4171e3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg deleted file mode 100644 index e80c7999..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-change-your-mind-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg deleted file mode 100644 index a605895b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg deleted file mode 100644 index eb5fc487..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg deleted file mode 100644 index c347e016..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg deleted file mode 100644 index f3a21b66..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-interrogation-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg deleted file mode 100644 index e8120387..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg deleted file mode 100644 index c278e37e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-invincible-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg deleted file mode 100644 index cbdc6525..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg deleted file mode 100644 index 652923ee..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg deleted file mode 100644 index 79e7d6bd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg deleted file mode 100644 index 45f5ff22..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-jujutsu-kaisen-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg deleted file mode 100644 index 480b2a8e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg deleted file mode 100644 index 062496fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-kaiju-no-8-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg deleted file mode 100644 index 009fbfc2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg deleted file mode 100644 index 3d348798..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-landman-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg deleted file mode 100644 index c85a210b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg deleted file mode 100644 index a9789909..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-last-samurai-standing-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg deleted file mode 100644 index 1249a8f3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg deleted file mode 100644 index b1229ed8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-lord-of-the-flies-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg deleted file mode 100644 index 3694bfc4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg deleted file mode 100644 index 08449aa9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-love-death-robots-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg deleted file mode 100644 index 5cea5b52..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg deleted file mode 100644 index 62ccf357..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-love-is-blind-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg deleted file mode 100644 index 9d46de1d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg deleted file mode 100644 index a1985217..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-maniac-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg deleted file mode 100644 index 20748a70..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg deleted file mode 100644 index abe1b2e2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg deleted file mode 100644 index bd459fde..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg deleted file mode 100644 index a8797fe5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-minx-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg deleted file mode 100644 index 21674163..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg deleted file mode 100644 index 925d5c49..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-mussolini-son-of-the-century-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg deleted file mode 100644 index f5b2f94c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-my-hero-academia-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg deleted file mode 100644 index d80e17c1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg deleted file mode 100644 index f86cd698..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-neon-genesis-evangelion-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg deleted file mode 100644 index 58805e67..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg deleted file mode 100644 index e7206b88..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-norsemen-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg deleted file mode 100644 index bc22e11a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-offspring-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg deleted file mode 100644 index 3fa4a282..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg deleted file mode 100644 index 286e6260..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-one-piece-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg deleted file mode 100644 index 0b768d54..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg deleted file mode 100644 index a6b147a4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-only-murders-in-the-building-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg deleted file mode 100644 index 23a242da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg deleted file mode 100644 index 43ac4428..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg deleted file mode 100644 index 34b1ed84..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-over-the-garden-wall-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg deleted file mode 100644 index da587ec3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg deleted file mode 100644 index 25ceea88..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-peaky-blinders-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg deleted file mode 100644 index e64c0268..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg deleted file mode 100644 index 1ea4711b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-pluribus-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg deleted file mode 100644 index d68a4060..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg deleted file mode 100644 index 97d481d6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-record-of-ragnarok-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rick-and-morty-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg deleted file mode 100644 index 2e7399c1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg deleted file mode 100644 index de0b32dd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-ripley-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg deleted file mode 100644 index fe7025e4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg deleted file mode 100644 index 296ac67a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rivals-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg deleted file mode 100644 index f0f7a19e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg deleted file mode 100644 index b698f8a6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-rooster-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg deleted file mode 100644 index 3186e89a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg deleted file mode 100644 index ed9358b3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sakamoto-days-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg deleted file mode 100644 index 445862dc..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg deleted file mode 100644 index cf50fc09..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-say-nothing-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg deleted file mode 100644 index 54d2a13e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg deleted file mode 100644 index 1b6e67f6..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-secret-level-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg deleted file mode 100644 index 8d081a15..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg deleted file mode 100644 index 2fb1c815..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg deleted file mode 100644 index a24c03bd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg deleted file mode 100644 index 5a29d6f9..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-severance-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-sex-and-the-city-1998-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg deleted file mode 100644 index bcc1c4b7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg deleted file mode 100644 index b493bc7b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-silo-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg deleted file mode 100644 index 4d2c02ad..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg deleted file mode 100644 index 559d6bd1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-six-feet-under-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg deleted file mode 100644 index c7ec326b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg deleted file mode 100644 index 2540dad7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-skymed-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg deleted file mode 100644 index feb777b2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg deleted file mode 100644 index a2e91496..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-so-you-think-you-can-dance-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg deleted file mode 100644 index cd0aec39..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-south-park-1997-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg deleted file mode 100644 index 8764e6eb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg deleted file mode 100644 index fc326930..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-spy-x-family-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg deleted file mode 100644 index 8d41cb22..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg deleted file mode 100644 index 4d254cb0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-100-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg deleted file mode 100644 index 53e1d593..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg deleted file mode 100644 index 72ca19db..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg deleted file mode 100644 index 9beacde2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-afterparty-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg deleted file mode 100644 index d72d7c7e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg deleted file mode 100644 index 7e4e7be2..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-baby-sitters-club-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bear-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg deleted file mode 100644 index e7727a79..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg deleted file mode 100644 index c78c5cf0..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-beast-in-me-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg deleted file mode 100644 index dd84deab..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg deleted file mode 100644 index f663eb8b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-bold-type-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg deleted file mode 100644 index a834d9fb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg deleted file mode 100644 index f67ff16f..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-boys-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg deleted file mode 100644 index 42256fc1..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg deleted file mode 100644 index a4039011..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-capture-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg deleted file mode 100644 index b25ab5f3..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg deleted file mode 100644 index 2eb48b62..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-diplomat-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-durrells-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg deleted file mode 100644 index 61a310d8..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg deleted file mode 100644 index f0332826..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-final-problem-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg deleted file mode 100644 index a358664c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg deleted file mode 100644 index fd252088..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-four-seasons-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg deleted file mode 100644 index 1de4a3ca..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg deleted file mode 100644 index 7bb38abd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-get-down-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg deleted file mode 100644 index d1758fef..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg deleted file mode 100644 index 034273eb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-hunting-wives-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg deleted file mode 100644 index fa28e38d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg deleted file mode 100644 index 7da462da..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-last-of-us-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg deleted file mode 100644 index ab6086bd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg deleted file mode 100644 index a7086d5e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-legend-of-korra-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg deleted file mode 100644 index 0b88fd4b..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg deleted file mode 100644 index dc387fcd..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg deleted file mode 100644 index c5d17d97..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg deleted file mode 100644 index 71407f36..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg deleted file mode 100644 index 327aa449..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg deleted file mode 100644 index 7ab2dbf5..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-night-manager-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg deleted file mode 100644 index b593444a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg deleted file mode 100644 index ad1ec8ff..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-pitt-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg deleted file mode 100644 index a267b4cb..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg deleted file mode 100644 index 5324dc75..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-tatami-galaxy-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg deleted file mode 100644 index e37f8d5c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg deleted file mode 100644 index 715d0c80..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-walk-in-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-white-lotus-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg deleted file mode 100644 index acca834e..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg deleted file mode 100644 index f5eb8f59..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-the-witcher-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg deleted file mode 100644 index ad972c17..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg deleted file mode 100644 index 8611eab4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-undercover-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg deleted file mode 100644 index f350a49c..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg deleted file mode 100644 index 547d3b7a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-upload-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg deleted file mode 100644 index 9ae58ec4..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg deleted file mode 100644 index 7d0ba17a..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-vinland-saga-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg deleted file mode 100644 index 03b1c335..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg deleted file mode 100644 index 8c02a0d7..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-widow-s-bay-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg deleted file mode 100644 index 960c2d23..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg deleted file mode 100644 index c7e16a57..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-yellowjackets-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg deleted file mode 100644 index ba65ca2d..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg b/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/app/src/debug/assets/wolfskin-discover/assets/tv-yu-yu-hakusho-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-discover/fixture.json b/app/src/debug/assets/wolfskin-discover/fixture.json deleted file mode 100644 index 450f0ece..00000000 --- a/app/src/debug/assets/wolfskin-discover/fixture.json +++ /dev/null @@ -1 +0,0 @@ -{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/app/src/debug/assets/wolfskin-library.json b/app/src/debug/assets/wolfskin-library.json deleted file mode 100644 index 76617014..00000000 --- a/app/src/debug/assets/wolfskin-library.json +++ /dev/null @@ -1 +0,0 @@ -{"profiles":[{"key":"justin","movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]},{"key":"mindel","movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}]},{"key":"shared","movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}]}],"seeAll":{"trending":[{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}},{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}},{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"duplicate":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]}} diff --git a/app/src/debug/assets/wolfskin-requests.json b/app/src/debug/assets/wolfskin-requests.json deleted file mode 100644 index 507ebd65..00000000 --- a/app/src/debug/assets/wolfskin-requests.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", - "items": [ - { - "id": "x1", - "mediaType": "tv", - "tmdbId": 11821, - "title": "The Abbott and Costello Show", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Downloading", - "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", - "ratings": [ - { - "mark": "tmdb", - "score": 7.2, - "percentage": false - } - ] - }, - { - "id": "x2", - "mediaType": "movie", - "tmdbId": 13483, - "title": "Awake", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "justin", - "actorName": "Justin", - "stage": "AlmostReady", - "poster": "movie-awake-2007-poster.jpg", - "ratings": [ - { - "mark": "rotten", - "score": 23, - "percentage": true - }, - { - "mark": "spilled", - "score": 55, - "percentage": true - }, - { - "mark": "imdb", - "score": 6.5, - "percentage": false - } - ] - }, - { - "id": "x3", - "mediaType": "tv", - "tmdbId": 70785, - "title": "Anne with an E", - "requesterKey": "shared", - "requesterName": "Shared", - "actorKey": "mindel", - "actorName": "Mindel", - "stage": "Searching", - "poster": "tv-anne-with-an-e-2017-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 63, - "percentage": true - } - ] - }, - { - "id": "x4", - "mediaType": "movie", - "tmdbId": 99861, - "title": "Avengers: Age of Ultron", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Waiting", - "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 75, - "percentage": true - }, - { - "mark": "upright", - "score": 82, - "percentage": true - }, - { - "mark": "imdb", - "score": 7.3, - "percentage": false - } - ] - }, - { - "id": "x5", - "mediaType": "movie", - "tmdbId": 1033148, - "title": "Die My Love", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "mindel", - "actorName": "Mindel", - "stage": "Failed", - "poster": "movie-die-my-love-2025-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 74, - "percentage": true - }, - { - "mark": "spilled", - "score": 45, - "percentage": true - }, - { - "mark": "imdb", - "score": 6, - "percentage": false - } - ] - }, - { - "id": "x6", - "mediaType": "movie", - "tmdbId": 416153, - "title": "Arctic Dogs", - "requesterKey": "shared", - "requesterName": "Shared", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Ready", - "poster": "movie-arctic-dogs-2019-poster.jpg", - "ratings": [ - { - "mark": "rotten", - "score": 13, - "percentage": true - }, - { - "mark": "upright", - "score": 60, - "percentage": true - }, - { - "mark": "imdb", - "score": 4.9, - "percentage": false - } - ] - }, - { - "id": "x7", - "mediaType": "movie", - "tmdbId": 76203, - "title": "12 Years a Slave", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "shared", - "actorName": "Shared", - "stage": "Waiting", - "poster": "movie-12-years-a-slave-2013-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 95, - "percentage": true - }, - { - "mark": "upright", - "score": 90, - "percentage": true - }, - { - "mark": "imdb", - "score": 8.1, - "percentage": false - } - ] - }, - { - "id": "x8", - "mediaType": "movie", - "tmdbId": 14069, - "title": "The Girl Who Leapt Through Time", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Waiting", - "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 87, - "percentage": true - }, - { - "mark": "upright", - "score": 90, - "percentage": true - }, - { - "mark": "imdb", - "score": 7.6, - "percentage": false - } - ] - } - ] -} diff --git a/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/app/src/debug/assets/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-shell.json b/app/src/debug/assets/wolfskin-shell.json deleted file mode 100644 index d99323a9..00000000 --- a/app/src/debug/assets/wolfskin-shell.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", - "profiles": [ - { - "key": "justin", - "name": "Justin", - "initial": "J", - "color": "#62c4ff", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Anime": { - "kind": "library", - "collection": "anime" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - }, - { - "key": "mindel", - "name": "Mindel", - "initial": "M", - "color": "#ff9d72", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - }, - { - "key": "shared", - "name": "Shared", - "initial": "S", - "color": "#9be38f", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - } - ] -} diff --git a/app/src/debug/assets/wolfskin-titles.json b/app/src/debug/assets/wolfskin-titles.json deleted file mode 100644 index c7559aaa..00000000 --- a/app/src/debug/assets/wolfskin-titles.json +++ /dev/null @@ -1 +0,0 @@ -{"profiles":[{"key":"justin","name":"Justin","initial":"J","color":"#62c4ff","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Anime":{"kind":"library","collection":"anime"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"resume":[],"nextUp":[{"id":"episode-three-monsters-1992-s1e6","title":"Three Monsters","year":1992,"type":"episode","runtimeSeconds":1421,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Animation","Action","Adventure"],"overview":"Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026","poster":"assets/episode-three-monsters-1992-s1e6-poster.jpg","backdrop":"assets/episode-three-monsters-1992-s1e6-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:59:21.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Yu Yu Hakusho","seasonNumber":1,"episodeNumber":6,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-all-hands-on-deck-class-1-a-2021-s5e1","title":"All Hands on Deck! Class 1-A","year":2021,"type":"episode","runtimeSeconds":1433,"seasonCount":null,"communityRating":6.938,"criticRating":null,"genres":["Animation","Action","Adventure"],"overview":"There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!","poster":"assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg","backdrop":"assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg","played":false,"playCount":2,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T04:04:50.0000000Z","lastPlayedAt":"2026-10-19T17:47:09.3028025Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"My Hero Academia","seasonNumber":5,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-erickerhead-2026-s9e6","title":"Erickerhead","year":2026,"type":"episode","runtimeSeconds":1357,"seasonCount":null,"communityRating":8.196,"criticRating":null,"genres":[],"overview":"Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!","poster":"assets/episode-erickerhead-2026-s9e6-poster.jpg","backdrop":"assets/episode-erickerhead-2026-s9e6-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-28T10:23:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Rick and Morty","seasonNumber":9,"episodeNumber":6,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-girls-interrupted-2007-s1e7","title":"Girls, Interrupted","year":2007,"type":"episode","runtimeSeconds":1642,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026","poster":"assets/episode-girls-interrupted-2007-s1e7-poster.jpg","backdrop":"assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-05T02:46:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Californication","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-announcement-2022-s1e7","title":"Announcement","year":2022,"type":"episode","runtimeSeconds":3012,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Action","Adventure","Drama"],"overview":"The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.","poster":"assets/episode-announcement-2022-s1e7-poster.jpg","backdrop":"assets/episode-announcement-2022-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:56:24.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Andor","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-south-american-biker-gangs-2026-s29e1","title":"South American Biker Gangs","year":2026,"type":"episode","runtimeSeconds":1335,"seasonCount":null,"communityRating":6.4,"criticRating":null,"genres":["Animation","Comedy"],"overview":"When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.","poster":"assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg","backdrop":"assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-01T06:40:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"South Park","seasonNumber":29,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}},{"key":"mindel","name":"Mindel","initial":"M","color":"#ff9d72","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"resume":[{"id":"episode-sex-and-the-country-2001-s4e9","title":"Sex and the Country","year":2001,"type":"episode","runtimeSeconds":1751,"seasonCount":null,"communityRating":5.9,"criticRating":null,"genres":["Comedy","Drama","Romance"],"overview":"Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026","poster":"assets/episode-sex-and-the-country-2001-s4e9-poster.jpg","backdrop":"assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":96.08,"progress":0.0549,"addedAt":"2026-07-17T10:53:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Sex and the City","seasonNumber":4,"episodeNumber":9,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"nextUp":[{"id":"episode-what-more-do-i-need-2008-s5e7","title":"What More Do I Need?","year":2008,"type":"episode","runtimeSeconds":2521,"seasonCount":null,"communityRating":8,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026","poster":"assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg","backdrop":"assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T14:37:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Desperate Housewives","seasonNumber":5,"episodeNumber":7,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}},{"id":"episode-new-day-2021-s1e2","title":"New Day","year":2021,"type":"episode","runtimeSeconds":3445,"seasonCount":null,"communityRating":7.143,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026","poster":"assets/episode-new-day-2021-s1e2-poster.jpg","backdrop":"assets/episode-new-day-2021-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T22:18:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The White Lotus","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-uncomfortability-2010-s1e2","title":"The Uncomfortability","year":2010,"type":"episode","runtimeSeconds":2687,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.","poster":"assets/episode-the-uncomfortability-2010-s1e2-poster.jpg","backdrop":"assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T11:47:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Offspring","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-episode-2-2016-s1e2","title":"Episode 2","year":2016,"type":"episode","runtimeSeconds":2766,"seasonCount":null,"communityRating":8.3,"criticRating":null,"genres":["Biography","Comedy","Drama"],"overview":"Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026","poster":"assets/episode-episode-2-2016-s1e2-poster.jpg","backdrop":"assets/episode-episode-2-2016-s1e2-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-31T07:29:17.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The Durrells","seasonNumber":1,"episodeNumber":2,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-remembrance-day-2026-s2e1","title":"Remembrance Day","year":2026,"type":"episode","runtimeSeconds":3822,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Fantasy","Romance"],"overview":"The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.","poster":"assets/episode-remembrance-day-2026-s2e1-poster.jpg","backdrop":"assets/episode-remembrance-day-2026-s2e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-19T03:50:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Outlander: Blood of My Blood","seasonNumber":2,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-one-of-us-2024-s1e7","title":"One of Us","year":2024,"type":"episode","runtimeSeconds":2555,"seasonCount":null,"communityRating":7.7,"criticRating":null,"genres":[],"overview":"Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026","poster":"assets/episode-one-of-us-2024-s1e7-poster.jpg","backdrop":"assets/episode-one-of-us-2024-s1e7-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:57:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"High Potential","seasonNumber":1,"episodeNumber":7,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-breakup-part-2-2001-s1e17","title":"The Breakup, Part 2","year":2001,"type":"episode","runtimeSeconds":2669,"seasonCount":null,"communityRating":7.4,"criticRating":null,"genres":["Comedy","Drama"],"overview":"After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.","poster":"assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg","backdrop":"assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T11:28:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Gilmore Girls","seasonNumber":1,"episodeNumber":17,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-i-heart-ny-2002-s4e18","title":"I Heart NY","year":2002,"type":"episode","runtimeSeconds":1905,"seasonCount":null,"communityRating":6.667,"criticRating":null,"genres":["Comedy","Drama","Romance"],"overview":"Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026","poster":"assets/episode-i-heart-ny-2002-s4e18-poster.jpg","backdrop":"assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:35:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Sex and the City","seasonNumber":4,"episodeNumber":18,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}}],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}},{"key":"shared","name":"Shared","initial":"S","color":"#9be38f","destinations":{"Home":{"kind":"home"},"Movies":{"kind":"library","collection":"movies"},"Shows":{"kind":"library","collection":"shows"},"Discover":{"kind":"discover","collection":"discover"}},"movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"resume":[{"id":"episode-episode-1-2025-s1e1","title":"Episode 1","year":2025,"type":"episode","runtimeSeconds":3618,"seasonCount":null,"communityRating":7.357,"criticRating":null,"genres":["Biography","Drama","History"],"overview":"Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.","poster":"assets/episode-episode-1-2025-s1e1-poster.jpg","backdrop":"assets/episode-episode-1-2025-s1e1-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":489.55,"progress":0.1353,"addedAt":"2026-07-17T13:52:48.0000000Z","lastPlayedAt":"2026-11-04T19:01:49.9731435Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Mussolini: Son of the Century","seasonNumber":1,"episodeNumber":1,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}}],"nextUp":[{"id":"episode-no-honeymoon-2020-s1e8","title":"No Honeymoon","year":2020,"type":"episode","runtimeSeconds":1499,"seasonCount":null,"communityRating":6.7,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026","poster":"assets/episode-no-honeymoon-2020-s1e8-poster.jpg","backdrop":"assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T19:00:50.0000000Z","lastPlayedAt":"2026-11-03T20:57:58.3314947Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Breeders","seasonNumber":1,"episodeNumber":8,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-notapusy-2005-s3e4","title":"Notapusy","year":2005,"type":"episode","runtimeSeconds":1334,"seasonCount":null,"communityRating":8.125,"criticRating":null,"genres":["Comedy"],"overview":"To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026","poster":"assets/episode-notapusy-2005-s3e4-poster.jpg","backdrop":"assets/episode-notapusy-2005-s3e4-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T12:17:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Arrested Development","seasonNumber":3,"episodeNumber":4,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-the-man-who-would-be-vogue-2018-s2e1","title":"The Man Who Would Be Vogue","year":2018,"type":"episode","runtimeSeconds":3203,"seasonCount":null,"communityRating":7.459,"criticRating":null,"genres":["Biography","Crime","Drama"],"overview":"The murder of Gianni Versace turns the eyes of the world onto Miami Beach.","poster":"assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg","backdrop":"assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:29:35.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"American Crime Story","seasonNumber":2,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-tomorrow-2024-s3e1","title":"Tomorrow","year":2024,"type":"episode","runtimeSeconds":2207,"seasonCount":null,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The next day and the days that led to it.","poster":"assets/episode-tomorrow-2024-s3e1-poster.jpg","backdrop":"assets/episode-tomorrow-2024-s3e1-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T13:46:39.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"The Bear","seasonNumber":3,"episodeNumber":1,"artwork":{"posterKind":"poster","backdropKind":"backdrop"}},{"id":"episode-episode-1-2025-s1e1","title":"Episode 1","year":2025,"type":"episode","runtimeSeconds":3618,"seasonCount":null,"communityRating":7.357,"criticRating":null,"genres":["Biography","Drama","History"],"overview":"Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.","poster":"assets/episode-episode-1-2025-s1e1-poster.jpg","backdrop":"assets/episode-episode-1-2025-s1e1-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":489.55,"progress":0.1353,"addedAt":"2026-07-17T13:52:48.0000000Z","lastPlayedAt":"2026-11-04T19:01:49.9731435Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","seriesTitle":"Mussolini: Son of the Century","seasonNumber":1,"episodeNumber":1,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"}}],"recentlyAdded":[{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}}],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"discoverySource":{"state":"materialized_snapshot","rankKnown":false,"selection":"First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order","observedAt":"2026-11-04T20:42:12.030108+00:00"}}],"cast":{"movie:14069":[{"name":"Riisa Naka","character":"Makoto Konno (voice)","img":"tqFHQiJUycuob3ytvilaAWDlbQ8.jpg","order":0},{"name":"Takuya Ishida","character":"Chiaki Mamiya (voice)","img":"aHQk6XxqyJinfvrVRADEIg31nOk.jpg","order":1},{"name":"Mitsutaka Itakura","character":"Kousuke Tsuda (voice)","img":"mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg","order":2},{"name":"Ayami Kakiuchi","character":"Yuri Hayakawa (voice)","img":"lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg","order":3},{"name":"Mitsuki Tanimura","character":"Kaho Fujitani (voice)","img":"xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg","order":4},{"name":"Yuki Sekido","character":"Miyuki Konno (voice)","img":"7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg","order":5},{"name":"Utawaka Katsura","character":"Makoto's Father (voice)","img":"j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg","order":6},{"name":"Midori Ando","character":"Makoto's Mother (voice)","img":"26R1LBiSOkkP08saSsz6LmVTY16.jpg","order":7},{"name":"Fumihiko Tachiki","character":"Fukushima (voice)","img":null,"order":8},{"name":"Keiko Yamamoto","character":"Auntie (voice)","img":null,"order":9},{"name":"Shiori Yokohari","character":"Moriko Uesugi (voice)","img":null,"order":10},{"name":"Sonoka Matsuoka","character":"Sekimi Nowake (voice)","img":null,"order":11},{"name":"Takayuki Handa","character":"Kato (voice)","img":null,"order":12},{"name":"Maho Kurashima","character":"Female Newscaster A (voice)","img":null,"order":13},{"name":"Keiko Aizawa","character":"School Nurse / Answering Machine (voice)","img":null,"order":14}],"movie:210479":[{"name":"Tom Hardy","character":"Ivan Locke","img":"d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","order":0},{"name":"Ruth Wilson","character":"Katrina (voice)","img":"fdFVatb9pt5dfT9tDK8tikKWafS.jpg","order":1},{"name":"Andrew Scott","character":"Donal (voice)","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":2},{"name":"Olivia Colman","character":"Bethan (voice)","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":3},{"name":"Tom Holland","character":"Eddie (voice)","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":4},{"name":"Ben Daniels","character":"Gareth (voice)","img":"phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","order":5},{"name":"Bill Milner","character":"Sean (voice)","img":"bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg","order":6},{"name":"Alice Lowe","character":"Sister Margaret (voice)","img":"v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg","order":7},{"name":"Danny Webb","character":"Cassidy (voice)","img":null,"order":8},{"name":"Lee Ross","character":"PC Davids (voice)","img":null,"order":9},{"name":"Silas Carson","character":"Dr. Gullu (voice)","img":null,"order":10},{"name":"Kirsty Dillon","character":"Gareth's Wife (voice)","img":null,"order":11}],"movie:318917":[{"name":"Oliver Masucci","character":"Adolf Hitler","img":"fjJc03z1cOlMxLZbC1qihsq3C37.jpg","order":0},{"name":"Fabian Busch","character":"Fabian Sawatzki","img":"lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg","order":1},{"name":"Christoph Maria Herbst","character":"Christoph Sensenbrink","img":"8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg","order":2},{"name":"Katja Riemann","character":"Bellini","img":"waayy0jpNbxLDQ8JO1uRDng8SLF.jpg","order":3},{"name":"Franziska Wulf","character":"Kr\u00f6meier","img":"yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg","order":4},{"name":"Lars Rudolph","character":"Shop Owner","img":"qiWu2gphSg64igYl3Nx0hEIOQF3.jpg","order":5},{"name":"Michael Kessler","character":"Michael Witzigmann","img":"sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg","order":6},{"name":"Thomas Thieme","character":"K\u00e4rrner","img":"omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg","order":7},{"name":"Frank Plasberg","character":"Frank Plasberg","img":null,"order":8},{"name":"Thomas M. K\u00f6ppl","character":"Etiquette Coach","img":null,"order":9},{"name":"Marc-Marvin Israel","character":"Football Boy","img":null,"order":10},{"name":"David Gebigke","character":"Football Boy","img":null,"order":11},{"name":"Paul Busche","character":"Football Boy","img":null,"order":12},{"name":"Gerdy Zint","character":"Pantomime","img":null,"order":13},{"name":"Nancy Maria Br\u00fcning","character":"Mother with Stroller","img":null,"order":14}],"movie:42994":[{"name":"Tsutomu Isobe","character":"Heinz Beckner (voice)","img":"qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg","order":0},{"name":"Koichi Yamadera","character":"Miguel Costrela (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":1},{"name":"Shozo Iizuka","character":"Ivanov (voice)","img":"msqe5A1q5YUUND0RhzB9dw1AEAO.jpg","order":2},{"name":"Shigeru Chiba","character":"Aoshima (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":3},{"name":"Gara Takashima","character":"Eva Friedel (voice)","img":"5L0kzTVITmOQdQ94NhicKM8ecCX.jpg","order":4},{"name":"Ami Hasegawa","character":"Emily (voice)","img":null,"order":5},{"name":"Hideyuki Hori","character":"Nobuo Tanaka (voice)","img":"fbWWkhe7GVSlhBljY89rJd01OnA.jpg","order":6},{"name":"Kenichi Ogata","character":"Oomaeda (voice)","img":"iQLEawIP2593yrGy22GzLSpnfgl.jpg","order":7},{"name":"Osamu Saka","character":"Kamata (voice)","img":null,"order":8},{"name":"Hisako Kyoda","character":"Grandma (voice)","img":null,"order":9},{"name":"Michio Hazama","character":"Nirasaki (voice)","img":null,"order":10},{"name":"Kayoko Fujii","character":"Sakiko (voice)","img":null,"order":11},{"name":"Ryoichi Tanaka","character":"General Officer (voice)","img":null,"order":12},{"name":"Tetsuya Iwanaga","character":"Nobuo's Brother (voice)","img":null,"order":13},{"name":"Yuu Hayashi","character":"Boy (voice)","img":null,"order":14}],"movie:123431":[{"name":"Snoop Dogg","character":"Self","img":"rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","order":0},{"name":"Dr. Dre","character":"Self","img":"xm2pYFBggiXSkFBV2I612i3fDd3.jpg","order":1},{"name":"Daz Dillinger","character":"Self","img":"nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg","order":2},{"name":"Angela Hunte","character":"Self","img":null,"order":3},{"name":"Ariel Rechtshaid","character":"Self","img":"lF06PTNYrlXeJ3DYApvOcPXJr87.jpg","order":4},{"name":"Dre Skull","character":"Self","img":null,"order":5},{"name":"Jahdan Blakkamoore","character":"Self","img":null,"order":6},{"name":"Bunny Wailer","character":"Self","img":"oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg","order":7},{"name":"Damian Marley","character":"Self","img":null,"order":8},{"name":"Louis Farrakhan","character":"Self","img":null,"order":9},{"name":"Shante Broadus","character":"Self","img":null,"order":10},{"name":"Cori Broadus","character":"Self","img":null,"order":11},{"name":"Maxine Stowe","character":"Self","img":null,"order":12},{"name":"Stewart Copeland","character":"Self","img":null,"order":13},{"name":"Diplo","character":"Self","img":null,"order":14}],"movie:1715492":[{"name":"John Scimone","character":"Self","img":null,"order":0},{"name":"Meghan Scimone","character":"Self","img":null,"order":1},{"name":"Patricia Sandoval","character":"Self","img":null,"order":2},{"name":"Nicholas Taliaferro","character":"Self","img":null,"order":3},{"name":"Stefania Vincenzi","character":"Self","img":null,"order":4},{"name":"Manrico Giampedroni","character":"Self","img":null,"order":5},{"name":"Rose Metcalf","character":"Self","img":null,"order":6},{"name":"Francesco Schettino","character":"Self (Archive Footage)","img":null,"order":7},{"name":"Manoj Singh","character":"Self","img":null,"order":8},{"name":"Francesco Boaria","character":"Self","img":null,"order":9},{"name":"Pier Luigi Foschi","character":"Self","img":null,"order":10},{"name":"Alessandro Cantelli-Forti","character":"Self","img":null,"order":11},{"name":"Barbie Latza Nadeau","character":"Self","img":null,"order":12}],"tv:83867":[{"name":"Diego Luna","character":"Cassian Andor","img":"qwbjcyDN37OHiln4a6P4LP31o7F.jpg","order":0},{"name":"Anton Lesser","character":"Major Partagaz","img":"x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg","order":3},{"name":"Muhannad Bhaier","character":"Wilmon","img":"5hn7WeXi6wguwxTPTSmcmzHAs9.jpg","order":4},{"name":"Stellan Skarsg\u00e5rd","character":"Luthen Rael","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":5},{"name":"Denise Gough","character":"Dedra Meero","img":"wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg","order":6},{"name":"Kyle Soller","character":"Syril Karn","img":"rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg","order":7},{"name":"Adria Arjona","character":"Bix Caleen","img":"hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg","order":8},{"name":"Genevieve O'Reilly","character":"Mon Mothma","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":9},{"name":"Faye Marsay","character":"Vel Sartha","img":null,"order":10},{"name":"Elizabeth Dulau","character":"Kleya Marki","img":null,"order":11},{"name":"Alastair Mackenzie","character":"Perrin Fertha","img":null,"order":501},{"name":"Dave Chapman","character":"B2EMO (voice)","img":null,"order":509},{"name":"Robert Emms","character":"Supervisor Lonni Jung","img":null,"order":538},{"name":"Michael Jenn","character":"Supervisor Lagret","img":null,"order":539},{"name":"Jacob James Beswick","character":"Attendant Heert","img":null,"order":619}],"tv:76479":[{"name":"Karl Urban","character":"Billy Butcher","img":"7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg","order":0},{"name":"Jack Quaid","character":"Hugh 'Hughie' Campbell","img":"320qW5yEbxpmyxQ3evmClJbtKag.jpg","order":1},{"name":"Antony Starr","character":"Homelander","img":"xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg","order":2},{"name":"Erin Moriarty","character":"Annie January / Starlight","img":"2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg","order":3},{"name":"Jessie T. Usher","character":"Reggie Franklin / A-Train","img":null,"order":5},{"name":"Laz Alonso","character":"Marvin T. 'Mother's Milk' Milk / M.M.","img":"nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg","order":6},{"name":"Chace Crawford","character":"Kevin Moskowitz / The Deep","img":"yWZILsr2mBQBCG1LOcCYYshIbts.jpg","order":7},{"name":"Tomer Capone","character":"Serge / Frenchie","img":"r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg","order":8},{"name":"Karen Fukuhara","character":"Kimiko Miyashiro","img":"sG6CWLdENCtzukNFkFYyPxqyRmS.jpg","order":9},{"name":"Nathan Mitchell","character":"Earving / Black Noir","img":null,"order":10},{"name":"Colby Minifie","character":"Ashley Barrett","img":null,"order":13},{"name":"Claudia Doumit","character":"Victoria 'Vic' Neuman","img":null,"order":14},{"name":"Cameron Crovetti","character":"Ryan","img":null,"order":16},{"name":"Dominique McElligott","character":"Maggie Shaw / Queen Maeve","img":null,"order":242},{"name":"Nneka Elliott","character":"Nneka Elliott","img":null,"order":894}],"tv:1215":[{"name":"David Duchovny","character":"Hank Moody","img":"fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg","order":0},{"name":"Natascha McElhone","character":"Karen van der Beek","img":"9pxaoasotR1pdCXYSfN1pkm0geO.jpg","order":1},{"name":"Evan Handler","character":"Charlie Runkle","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":2},{"name":"Pamela Adlon","character":"Marcy Runkle","img":"5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","order":3},{"name":"Rachel Miner","character":"Dani","img":null,"order":4},{"name":"Madeleine Martin","character":"Becca Moody","img":"hOntuFYAMwn9QaXT0AwLCPU79ce.jpg","order":5},{"name":"Madeline Zima","character":"Mia Lewis","img":"6hmUs5Q1EETapn32yKNt3c3i1sn.jpg","order":6},{"name":"Callum Keith Rennie","character":"Lew Ashby","img":null,"order":7},{"name":"Carla Gallo","character":"Daisy","img":null,"order":513},{"name":"Embeth Davidtz","character":"Felicia Koons","img":null,"order":514},{"name":"Kathleen Turner","character":"Sue Collini","img":null,"order":522},{"name":"Jason Beghe","character":"Richard Bates","img":"hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","order":534},{"name":"Stephen Tobolowsky","character":"Stu Beggs","img":"uL8yfcm0dCFYFQt052kczVQVvac.jpg","order":671},{"name":"Michael Imperioli","character":"Rick Rath","img":null,"order":843},{"name":"Oliver Cooper","character":"Levon","img":null,"order":844}],"tv:1437":[{"name":"Nathan Fillion","character":"Mal Reynolds","img":"aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg","order":0},{"name":"Gina Torres","character":"Zo\u00eb Washburne","img":"ukDNHgvEgdU95eaw6pngiAPucv4.jpg","order":1},{"name":"Alan Tudyk","character":"Hoban Washburne","img":"jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","order":2},{"name":"Morena Baccarin","character":"Inara Serra","img":"4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg","order":3},{"name":"Adam Baldwin","character":"Jayne Cobb","img":"lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","order":4},{"name":"Jewel Staite","character":"Kaylee Frye","img":"ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg","order":5},{"name":"Sean Maher","character":"Simon Tam","img":"o7splcOIVAdbHnwohJOUAvXWVlk.jpg","order":6},{"name":"Michael Fairman","character":"Adelai Niska","img":null,"order":7},{"name":"Summer Glau","character":"River Tam","img":"rDTxblyw0AwU5oSeksgP7GwMyrF.jpg","order":8},{"name":"Ron Glass","character":"Derrial Book","img":null,"order":9},{"name":"Gregory Itzin","character":"Magistrate Higgins","img":null,"order":10},{"name":"Isabella Hofmann","character":"Regan Tam","img":null,"order":11},{"name":"Jeff Ricketts","character":"Man","img":null,"order":540},{"name":"Dennis Cockrum","character":"Other Man","img":null,"order":541},{"name":"Mark Sheppard","character":"Badger","img":null,"order":613}],"tv:67070":[{"name":"Phoebe Waller-Bridge","character":"Fleabag","img":"5VbadQsYCKdiJysP01LJFPmYB8A.jpg","order":0},{"name":"Sian Clifford","character":"Claire","img":"rIqcwewXdkrDTMTwX56QPa7MNTC.jpg","order":1},{"name":"Jenny Rainsford","character":"Boo","img":"pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg","order":2},{"name":"Andrew Scott","character":"The Priest","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":3},{"name":"Ben Aldridge","character":"Arsehole Guy","img":null,"order":507},{"name":"Jo Martin","character":"Pam","img":null,"order":509},{"name":"Olivia Colman","character":"Godmother","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":510},{"name":"Bill Paterson","character":"Dad","img":"mk5vpYLmER7asqOhEwVKXD3P3YE.jpg","order":511},{"name":"Jamie Demetriou","character":"Bus Rodent","img":null,"order":512},{"name":"Hugh Skinner","character":"Harry","img":"7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg","order":513},{"name":"Hugh Dennis","character":"Bank Manager","img":null,"order":514},{"name":"Brett Gelman","character":"Martin","img":"ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg","order":515},{"name":"Anthony Welsh","character":"Jack","img":null,"order":516},{"name":"Kae Alexander","character":"Elaine","img":null,"order":523},{"name":"David Hargreaves","character":"Chatty Joe","img":null,"order":530}],"tv:40075":[{"name":"Jason Ritter","character":"Dipper Pines (voice)","img":null,"order":0},{"name":"Kristen Schaal","character":"Mabel Pines (voice)","img":null,"order":1},{"name":"Alex Hirsch","character":"Grunkle Stan / Soos Ramirez / Bill Cipher / Old Man McGucket (voice)","img":null,"order":2},{"name":"Linda Cardellini","character":"Wendy Corduroy (voice)","img":null,"order":3},{"name":"Will Forte","character":"Tyler Cutebiker (voice)","img":null,"order":13},{"name":"Gregg Turkington","character":"Young Toby Determined (voice)","img":null,"order":166},{"name":"Carl Faruolo","character":"Grenda (voice)","img":null,"order":170},{"name":"Jennifer Coolidge","character":"Lazy Susan (voice)","img":null,"order":175},{"name":"Dee Bradley Baker","character":"Waddles (voice) / Additional Voices (voice)","img":null,"order":399},{"name":"John DiMaggio","character":"Manly Dan / Pinball Cowboy Skeleton (voice)","img":null,"order":469},{"name":"Kevin Michael Richardson","character":"Sheriff Blubs / Ghost / Ancient Heads / Basketball Player / Manly Dan (voice)","img":null,"order":561},{"name":"Grey DeLisle","character":"Mrs. Gleeful (voice)","img":null,"order":582},{"name":"Matt Chapman","character":"Mermando / Additional Voices (voice)","img":null,"order":591},{"name":"Niki Yang","character":"Candy Chiu (voice)","img":null,"order":650},{"name":"T.J. Miller","character":"Robbie Valentino (voice)","img":null,"order":664}],"tv:95557":[{"name":"Steven Yeun","character":"Mark Grayson / Invincible (voice)","img":null,"order":0},{"name":"Sandra Oh","character":"Debbie Grayson (voice)","img":null,"order":1},{"name":"J.K. Simmons","character":"Nolan Grayson / Omni-Man (voice)","img":null,"order":2},{"name":"Dan Navarro","character":"Hazmat Responder / Additional Voices (voice)","img":null,"order":547},{"name":"Ami Shukla","character":"Additional Voices (voice)","img":null,"order":548},{"name":"Nyima Funk","character":"Additional Voices (voice)","img":null,"order":558},{"name":"Zazie Beetz","character":"Amber (voice)","img":null,"order":567},{"name":"Walton Goggins","character":"Cecil (voice)","img":null,"order":568},{"name":"Gillian Jacobs","character":"Eve (voice)","img":null,"order":574},{"name":"Jason Mantzoukas","character":"Rex / Zombie #2 (voice)","img":null,"order":588},{"name":"Chris Diamantopoulos","character":"Pete / Donald / Todd (voice)","img":null,"order":596},{"name":"Khary Payton","character":"Black Samson / Martian (voice)","img":null,"order":601},{"name":"Ross Marquand","character":"Immortal / Rudy / Kursk (voice)","img":null,"order":628},{"name":"Grey DeLisle","character":"Monster Girl / Thula / Woman (voice)","img":null,"order":656},{"name":"Bobby Kesselman","character":"Additional Voices (voice)","img":null,"order":734}],"tv:33880":[{"name":"Janet Varney","character":"Korra (voice)","img":null,"order":0},{"name":"David Faustino","character":"Mako (voice)","img":null,"order":1},{"name":"P.J. Byrne","character":"Bolin (voice)","img":null,"order":2},{"name":"Seychelle Gabriel","character":"Asami Sato (voice)","img":null,"order":3},{"name":"Mindy Sterling","character":"Lin Beifong (voice)","img":null,"order":6},{"name":"Adrian LaTourelle","character":"Unalaq (voice)","img":null,"order":9},{"name":"J.K. Simmons","character":"Mushi (voice)","img":null,"order":68},{"name":"Dee Bradley Baker","character":"Juicy (voice)","img":null,"order":324},{"name":"Kiernan Shipka","character":"Jinora (voice)","img":null,"order":500},{"name":"Darcy Rose Byrnes","character":"Ikki (voice)","img":null,"order":503},{"name":"Logan Wells","character":"Meelo (voice)","img":null,"order":504},{"name":"John Michael Higgins","character":"Varrick (voice)","img":null,"order":549},{"name":"Richard Riehle","character":"Bumi (voice)","img":null,"order":551},{"name":"Maria Bamford","character":"Pema (voice)","img":null,"order":556},{"name":"Jeff Bennett","character":"Shiro Shinobi / Fishmonger / Ring Announcer (voice)","img":null,"order":568}],"tv:86831":[{"name":"Josh Brener","character":"K-VRC (voice)","img":null,"order":237},{"name":"Matthew Yang King","character":"Host (voice)","img":null,"order":520},{"name":"Nolan North","character":"Vladimir (voice)","img":null,"order":555},{"name":"Ike Amadi","character":"Platoon Sergeant (voice)","img":null,"order":566},{"name":"Time Winters","character":"Dicko (voice)","img":null,"order":584},{"name":"Emily O'Brien","character":"Eve (voice)","img":null,"order":592},{"name":"Chris Cox","character":"Male Backgound (voice)","img":null,"order":592},{"name":"Noshir Dalal","character":"Beaumont (voice)","img":null,"order":599},{"name":"Michelle C. Bonilla","character":"Commander / Pilot (voice)","img":null,"order":599},{"name":"Stanton Lee","character":"Sergeant Howard (voice)","img":null,"order":602},{"name":"Fred Tatasciore","character":"Creature / TV Santa (voice)","img":null,"order":608},{"name":"Scott Whyte","character":"Trot (voice)","img":null,"order":618},{"name":"Caitlin McKenna","character":"Additional Voices (voice)","img":null,"order":622},{"name":"Piotr Michael","character":"Investor (voice)","img":null,"order":636},{"name":"Chris Parnell","character":"The Cat (voice)","img":null,"order":653}],"tv:68126":[{"name":"Trond Fausa Aurv\u00e5g","character":"Rufus","img":null,"order":0},{"name":"Jon \u00d8igarden","character":"Jarl Varg","img":null,"order":1},{"name":"K\u00e5re Conradi","character":"Orm","img":null,"order":2},{"name":"Silje Torp","character":"Fr\u00f8ya","img":null,"order":3},{"name":"Mikkel Bratt Silset","character":"Ragnar","img":null,"order":4},{"name":"Nils J\u00f8rgen Kaalstad","character":"Arvid","img":null,"order":5},{"name":"Kristine Riis","character":"Liv","img":null,"order":6},{"name":"Bj\u00f8rn Hallgeir Myrene","character":"Torstein Hund","img":null,"order":7},{"name":"Marian Saastad Ottesen","character":"Hildur","img":null,"order":8},{"name":"Nikis Theophilakis","character":"Olvar / Eigil","img":null,"order":9},{"name":"\u00d8ystein Martinsen","character":"Kark","img":null,"order":10},{"name":"Christian Skolmen","character":"H\u00e5kon","img":null,"order":11},{"name":"Erik Aleksander Schjerven","character":"Magnus","img":null,"order":12},{"name":"Ingar Helge Gimle","character":"Nomadeh\u00f8vding","img":null,"order":13},{"name":"Finn Schau","character":"Lovgiveren","img":null,"order":14}],"tv:60625":[{"name":"Justin Roiland","character":"Rick Sanchez / Morty Smith (voice)","img":null,"order":0},{"name":"Chris Parnell","character":"Jerry Smith (voice)","img":null,"order":1},{"name":"Spencer Grammer","character":"Summer Smith (voice)","img":null,"order":2},{"name":"Sarah Chalke","character":"Beth Smith (voice)","img":null,"order":3},{"name":"Ian Cardoni","character":"Rick Sanchez (voice)","img":null,"order":4},{"name":"Harry Belden","character":"Morty Smith (voice)","img":null,"order":6},{"name":"Dan Harmon","character":"Alejandro (voice)","img":null,"order":688},{"name":"Kari Wahlgren","character":"Roy's Wife (voice)","img":null,"order":715},{"name":"Maurice LaMarche","character":"Roy's Teacher / Gromflamite Fed (voice)","img":null,"order":743},{"name":"Tom Kenny","character":"Conroy / Federation Admiral / Additional Voices (voice)","img":null,"order":750},{"name":"Ryan Ridley","character":"Head Vampire (voice)","img":null,"order":752},{"name":"Brandon Johnson","character":"Mr. Goldenfold / Black Mailman (voice)","img":null,"order":755},{"name":"Nolan North","character":"Jerry's High School Friend (voice)","img":null,"order":757},{"name":"Rob Schrab","character":"Town Resident (voice)","img":null,"order":768},{"name":"Nick Reczynski","character":"Snake Humanoid (voice)","img":null,"order":815}],"tv:261579":[{"name":"Keanu Reeves","character":"Pilot (voice)","img":null,"order":500},{"name":"Parry Shen","character":"MC (young) (voice)","img":null,"order":501},{"name":"Ping Wu","character":"MC (Old) (voice)","img":null,"order":502},{"name":"Lydia Look","character":"Li (voice)","img":null,"order":503},{"name":"Nelson Lee","character":"Sean (voice)","img":null,"order":504},{"name":"Feodor Chin","character":"Thug (voice)","img":null,"order":505},{"name":"Rae Lim","character":"MC (Child) (voice)","img":null,"order":506},{"name":"Erin Yvette","character":"The Voice (voice)","img":null,"order":507},{"name":"Temuera Morrison","character":"Old Salt (voice)","img":null,"order":508},{"name":"Patrick Schwarzenegger","character":"The Kid (voice)","img":null,"order":509},{"name":"Steve Blum","character":"Dispatch / Mechanic (voice)","img":null,"order":510},{"name":"Noah Manzoor","character":"Solon (voice)","img":null,"order":511},{"name":"Madeleine Knight","character":"Mora (voice)","img":null,"order":512},{"name":"Aleks Le","character":"Yi Xing (voice)","img":null,"order":556},{"name":"Fred Tatasciore","character":"Helldiver (voice)","img":null,"order":558}],"tv:125988":[{"name":"Rebecca Ferguson","character":"Juliette Nichols","img":null,"order":0},{"name":"Common","character":"Robert Sims","img":null,"order":1},{"name":"Chinaza Uche","character":"Paul Billings","img":null,"order":2},{"name":"Shane McRae","character":"Knox","img":null,"order":4},{"name":"Remmie Milner","character":"Shirley Campbell","img":null,"order":6},{"name":"Alexandria Riley","character":"Camille Sims","img":null,"order":7},{"name":"Billy Postlethwaite","character":"Hank Murphy","img":null,"order":12},{"name":"Tim Robbins","character":"Bernard Holland","img":null,"order":15},{"name":"Clare Perkins","character":"Carla McLain","img":null,"order":17},{"name":"Harriet Walter","character":"Martha Walker","img":null,"order":18},{"name":"Avi Nash","character":"Lukas Kyle","img":null,"order":20},{"name":"Rick Gomez","character":"Patrick Kennedy","img":null,"order":21},{"name":"Olatunji Ayofe","character":"Teddy","img":null,"order":502},{"name":"Chipo Chung","character":"Sandy","img":null,"order":506},{"name":"Caitlin Zoz","character":"Kathleen Billings","img":null,"order":542}],"tv:2190":[{"name":"Isaac Hayes","character":"Chef (voice)","img":null,"order":2},{"name":"Mary Kay Bergman","character":"Liane Cartman / Wendy Testaburger / Sharon Marsh / Sheila Broflovski (voice)","img":null,"order":3},{"name":"Mona Marshall","character":"Sheila Broflovski (voice)","img":null,"order":4},{"name":"Sebastian Yu","character":"Ike Broflovski (voice)","img":null,"order":7},{"name":"Jessie Jo Thomas","character":"","img":null,"order":8},{"name":"Milan Agnone","character":"Ike Broflovski (voice)","img":null,"order":9},{"name":"Trey Parker","character":"Eric Cartman / Stan Marsh / Randy Marsh / Mr. Garrison (voice)","img":null,"order":10},{"name":"Matt Stone","character":"Kyle Broflovski / Kenny McCormick / Butters Stotch (voice)","img":null,"order":11},{"name":"Jennifer Howell","character":"Bebe Stevens (voice)","img":null,"order":11},{"name":"April Stewart","character":"Wendy Testaburger / Liane Cartman / Dora M\u00e1rquez (voice)","img":null,"order":65},{"name":"Eliza Schneider","character":"Liane Cartman (voice)","img":null,"order":79},{"name":"Kyle McCulloch","character":"Excalibur Hotel Employee (voice)","img":null,"order":512},{"name":"Jonathan Kimmel","character":"","img":null,"order":528},{"name":"John Hansen","character":"","img":null,"order":534},{"name":"Adrien Beard","character":"Tolkien Black (voice)","img":null,"order":554}],"tv:30984":[{"name":"Masakazu Morita","character":"Ichigo Kurosaki (voice)","img":null,"order":0},{"name":"Fumiko Orikasa","character":"Rukia Kuchiki (voice)","img":null,"order":1},{"name":"Hiroki Yasumoto","character":"Yasutora Sado (voice)","img":null,"order":2},{"name":"Yuki Matsuoka","character":"Orihime Inoue (voice)","img":null,"order":3},{"name":"Noriaki Sugiyama","character":"Uryu Ishida (voice)","img":null,"order":4},{"name":"Kentaro Ito","character":"Renji Abarai (voice)","img":null,"order":47},{"name":"Ryotaro Okiayu","character":"Byakuya Kuchki (voice)","img":null,"order":48},{"name":"Mitsuaki Madono","character":"Kon (voice)","img":null,"order":49},{"name":"Toshiyuki Morikawa","character":"Kaname T\u014dsen (voice)","img":null,"order":133},{"name":"Kiyoyuki Yanada","character":"Tessai Tsukabishi (voice)","img":null,"order":500},{"name":"Katsuyuki Konishi","character":"Shuhei Hisagi (voice)","img":null,"order":501},{"name":"Shin-ichiro Miki","character":"Kisuke Urahara (voice)","img":null,"order":504},{"name":"Masaya Onosaka","character":"Gejigeji mukade (voice)","img":null,"order":505},{"name":"Ryusei Nakao","character":"Mayuri Kurotsuchi (voice)","img":null,"order":509},{"name":"Takayuki Sugo","character":"Yhwach (voice)","img":null,"order":517}],"tv:114410":[{"name":"Kikunosuke Toya","character":"Denji (voice)","img":null,"order":0},{"name":"Tomori Kusunoki","character":"Makima (voice)","img":null,"order":1},{"name":"Shogo Sakata","character":"Aki Hayakawa (voice)","img":null,"order":2},{"name":"Fairouz Ai","character":"Power (voice)","img":null,"order":3},{"name":"Mariya Ise","character":"Himeno (voice)","img":null,"order":500},{"name":"Marina Inoue","character":"Child Denji (voice)","img":null,"order":507},{"name":"Kenjiro Tsuda","character":"Kishibe (voice)","img":null,"order":519},{"name":"Taku Yashiro","character":"Arai (voice)","img":null,"order":520},{"name":"Karin Takahashi","character":"Kobeni (voice)","img":null,"order":521},{"name":"Shiori Izawa","character":"Pochita (voice)","img":null,"order":522},{"name":"Mitsutoshi Shundo","character":"Announcer (voice)","img":null,"order":528},{"name":"Hironori Kondoh","character":"Eternity Devil (voice)","img":null,"order":538},{"name":"Noriyuki Tsuyuki","character":"Henchman D (voice)","img":null,"order":542},{"name":"Daiki Hamano","character":"Samurai Sword (voice)","img":null,"order":546},{"name":"You Taichi","character":"Sawatari (voice)","img":null,"order":547}],"tv:105248":[{"name":"KENN","character":"David Martinez (voice)","img":null,"order":0},{"name":"Koki Uchiyama","character":"D (voice)","img":null,"order":1},{"name":"Momoka Terasawa","character":"Roman (voice)","img":null,"order":2},{"name":"Akari Kito","character":"Talia (voice)","img":null,"order":3},{"name":"Kentaro Tone","character":"Weak (voice)","img":null,"order":4},{"name":"Kento Shiraishi","character":"Maelstrom (voice)","img":null,"order":531},{"name":"Kenjiro Tsuda","character":"Ripperdoc (voice)","img":null,"order":568},{"name":"Aoi Yuki","character":"Lucy (voice)","img":null,"order":571},{"name":"Kazuhiko Inoue","character":"Faraday (voice)","img":null,"order":573},{"name":"Tomoyo Kurosawa","character":"Rebecca (voice)","img":null,"order":574},{"name":"Takako Honda","character":"Kiwi (voice)","img":null,"order":575},{"name":"Ryuho Nagaoka","character":"Driver (voice)","img":null,"order":627},{"name":"Akemi Okamura","character":"Avida (voice)","img":null,"order":668},{"name":"Aoi Koga","character":"Capri (voice)","img":null,"order":669},{"name":"Yasunori Matsumoto","character":"Kiril (voice)","img":null,"order":670}],"tv:240411":[{"name":"Shion Wakayama","character":"Momo Ayase (voice)","img":null,"order":0},{"name":"Natsuki Hanae","character":"Ken 'Okarun' Takakura (voice)","img":null,"order":1},{"name":"Mayumi Tanaka","character":"Turbo Granny (voice)","img":null,"order":2},{"name":"Nana Mizuki","character":"Seiko (voice)","img":null,"order":3},{"name":"Kaori Maeda","character":"Miko (voice)","img":null,"order":500},{"name":"Miyu Tomita","character":"Muko (voice)","img":null,"order":501},{"name":"Ayane Sakura","character":"Aira Shiratori (voice)","img":null,"order":519},{"name":"Aya Yamane","character":"Tsubame (voice)","img":null,"order":520},{"name":"Natsu Yorita","character":"Anzu (voice)","img":null,"order":521},{"name":"Koji Seki","character":"Male Teacher (voice)","img":null,"order":527},{"name":"Koseki Kogure","character":"Student C (voice)","img":null,"order":527},{"name":"Yukiko Motoyoshi","character":"Female Student A (voice)","img":null,"order":528},{"name":"Kazuya Nakai","character":"Serpoian A / Serpoian B / Serpoian C (voice)","img":null,"order":533},{"name":"Tomokazu Seki","character":"Dover Demon (voice)","img":null,"order":536},{"name":"Kaito Ishikawa","character":"Jin 'Jiji' Enjouji (voice)","img":null,"order":541}],"tv:86031":[{"name":"Yusuke Kobayashi","character":"Senku Ishigami (voice)","img":null,"order":0},{"name":"Makoto Furukawa","character":"Taiju Oki (voice)","img":null,"order":1},{"name":"Kana Ichinose","character":"Yuzuriha Ogawa (voice)","img":null,"order":2},{"name":"Manami Numakura","character":"Kohaku (voice)","img":null,"order":3},{"name":"Yuichi Nakamura","character":"Tsukasa (voice)","img":null,"order":4},{"name":"Gen Sato","character":"Chrome (voice)","img":null,"order":5},{"name":"Ayumu Murase","character":"Ginro (voice)","img":null,"order":6},{"name":"Yuya Hirose","character":"Science Club Member (voice)","img":null,"order":501},{"name":"Aoi Ichikawa","character":"Science Club Member (voice)","img":null,"order":502},{"name":"Haruki Ishiya","character":"Science Club Member (voice)","img":null,"order":503},{"name":"Kouichi Souma","character":"Teacher (voice)","img":null,"order":504},{"name":"Keiji Fujiwara","character":"Byakuya Ishigami (voice)","img":null,"order":508},{"name":"Kengo Kawanishi","character":"Asagiri Gen (voice)","img":null,"order":526},{"name":"Ryota Suzuki","character":"Nanami Ryuusui (voice)","img":null,"order":527},{"name":"Karin Takahashi","character":"Suika (Voice)","img":null,"order":528}],"tv:209867":[{"name":"Atsumi Tanezaki","character":"Frieren (voice)","img":null,"order":0},{"name":"Kana Ichinose","character":"Fern (voice)","img":null,"order":1},{"name":"Chiaki Kobayashi","character":"Stark (voice)","img":null,"order":2},{"name":"Nobuhiko Okamoto","character":"Himmel (voice)","img":null,"order":3},{"name":"Hiroki Touchi","character":"\ud558\uc774\ud130 (voice)","img":null,"order":4},{"name":"Yoji Ueda","character":"Eisen (voice)","img":null,"order":5},{"name":"Kento Shiraishi","character":"Falsch (voice)","img":null,"order":550},{"name":"Azumi Waki","character":"Kanne (voice)","img":null,"order":565},{"name":"Sayumi Suzushiro","character":"Lawine (voice)","img":null,"order":566},{"name":"Ikumi Hasegawa","character":"\u00dcbel (voice)","img":null,"order":567},{"name":"Haruka Terui","character":"Sense (voice)","img":null,"order":568},{"name":"Tarusuke Shingaki","character":"Genau (voice)","img":null,"order":569},{"name":"Masafumi Kobatake","character":"Mining Worker (voice)","img":null,"order":572},{"name":"Jiro Saito","character":"Denken (voice)","img":null,"order":576},{"name":"Eiji Hanawa","character":"Richter (voice)","img":null,"order":577}],"tv:31911":[{"name":"Romi Park","character":"Edward Elric (voice)","img":null,"order":0},{"name":"Shin-ichiro Miki","character":"Roy Mustang (voice)","img":null,"order":2},{"name":"Fumiko Orikasa","character":"Riza Hawkeye (voice)","img":null,"order":3},{"name":"Megumi Takamoto","character":"Winry Rockbell (voice)","img":null,"order":4},{"name":"Rie Kugimiya","character":"Alphonse Elric (voice)","img":null,"order":341},{"name":"Hidekatsu Shibata","character":"King Bradley (voice)","img":null,"order":505},{"name":"Kenji Utsumi","character":"Louis Armstrong (voice)","img":null,"order":507},{"name":"Kenta Miyake","character":"Scar (voice)","img":null,"order":535},{"name":"Minami Takayama","character":"Envy (voice)","img":null,"order":541},{"name":"Iemasa Kayumi","character":"Father (voice)","img":null,"order":552},{"name":"Biichi Sato","character":"Doctor (voice)","img":null,"order":559},{"name":"Mamoru Miyano","character":"Ling Yao (voice)","img":null,"order":587},{"name":"Mai Gotou","character":"May Chang (voice)","img":null,"order":590},{"name":"Yuki Hayashi","character":"Military (voice)","img":null,"order":630},{"name":"Takashi Hikida","character":"Ishvalan A (voice)","img":null,"order":689}],"tv:95479":[{"name":"Takaya Kuroda","character":"Masamichi Yaga (voice)","img":null,"order":549},{"name":"Nobunaga Shimazaki","character":"Mahito (voice)","img":null,"order":600},{"name":"Tomokazu Seki","character":"Panda (voice)","img":null,"order":602},{"name":"Mikako Komatsu","character":"Maki Zen'in (voice)","img":null,"order":603},{"name":"Kenjiro Tsuda","character":"Kento Nanami (voice)","img":null,"order":605},{"name":"Junichi Suwabe","character":"Ryomen Sukuna (voice)","img":null,"order":612},{"name":"Shigeru Chiba","character":"Grandfather (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":654},{"name":"Aya Endo","character":"Shoko Ieiri (voice)","img":null,"order":666},{"name":"Mitsuo Iwata","character":"Spectator (voice)","img":null,"order":685},{"name":"Yuichi Nakamura","character":"Satoru Gojo (voice)","img":null,"order":686},{"name":"Asami Seto","character":"Transfigured Human (voice)","img":null,"order":687},{"name":"Junya Enoki","character":"Yuji Itadori (voice)","img":null,"order":689},{"name":"Takahiro Sakurai","character":"Suguru Geto (voice)","img":null,"order":718},{"name":"Yoshiko Sakakibara","character":"Tengen (voice)","img":null,"order":739},{"name":"Yuma Uchida","character":"Megumi Fushiguro (voice)","img":null,"order":849}],"tv:207468":[{"name":"Masaya Fukunishi","character":"Kafka Hibino / Kaiju No. 8 (voice)","img":null,"order":0},{"name":"Asami Seto","character":"Mina Ashiro (voice)","img":null,"order":1},{"name":"Wataru Katoh","character":"Reno Ichikawa (voice)","img":null,"order":2},{"name":"Kengo Kawanishi","character":"Soshiro Hoshina (voice)","img":null,"order":4},{"name":"Fairouz Ai","character":"Kaiju No. 15 (voice)","img":null,"order":322},{"name":"Nene Hieda","character":"Akari Minase (voice)","img":null,"order":546},{"name":"Tomohiro Ono","character":"Shimoen (voice)","img":null,"order":551},{"name":"Genta Nakamura","character":"Operator (voice)","img":null,"order":569},{"name":"Takayuki Sugo","character":"Keiji Itami (voice)","img":null,"order":575},{"name":"Ryosuke Higa","character":"Ministry of Defense Staff Member (voice)","img":null,"order":577},{"name":"Shota Yamamoto","character":"Mebari (voice)","img":null,"order":612},{"name":"Yuuki Shin","character":"Iharu Furuhashi (voice)","img":null,"order":625},{"name":"Keisuke Komoto","character":"Haruichi Izumo (voice)","img":null,"order":626},{"name":"Shunsuke Takeuchi","character":"Aoi Kaguragi (voice)","img":null,"order":627},{"name":"Sayaka Senbongi","character":"Konomi Okonogi (voice)","img":null,"order":628}],"tv:65930":[{"name":"Daiki Yamashita","character":"Izuku Midoriya (voice)","img":null,"order":0},{"name":"Tarusuke Shingaki","character":"Mirio Togata (voice)","img":null,"order":1},{"name":"Nobuhiko Okamoto","character":"Katsuki Bakugo (voice)","img":null,"order":2},{"name":"Kenta Miyake","character":"All Might (voice)","img":null,"order":3},{"name":"Yuki Kaji","character":"Shoto Todoroki (voice)","img":null,"order":6},{"name":"Seiran Kobayashi","character":"Eri (voice)","img":null,"order":8},{"name":"Ayane Sakura","character":"Ochako Uraraka (voice)","img":null,"order":48},{"name":"Aoi Yuki","character":"Setsuna Tokage (voice)","img":null,"order":51},{"name":"Junichi Suwabe","character":"Jurota Shishida (voice)","img":null,"order":60},{"name":"Kaito Ishikawa","character":"Tenya Iida (voice)","img":null,"order":91},{"name":"Kenjiro Tsuda","character":"Overhaul (voice)","img":null,"order":505},{"name":"Koki Uchiyama","character":"Tomura Shigaraki (voice)","img":null,"order":546},{"name":"Hiro Shimono","character":"Dabi (voice)","img":null,"order":571},{"name":"Kiyotaka Furushima","character":"Kido (voice)","img":null,"order":583},{"name":"Tetsu Inada","character":"Endeavor (voice)","img":null,"order":585}],"tv:890":[{"name":"Megumi Ogata","character":"Shinji Ikari (voice)","img":null,"order":0},{"name":"Kotono Mitsuishi","character":"Misato Katsuragi (voice)","img":null,"order":1},{"name":"Megumi Hayashibara","character":"Rei Ayanami (voice)","img":null,"order":2},{"name":"Yuko Miyamura","character":"Asuka Langley Soryu (voice)","img":null,"order":3},{"name":"Yuriko Yamaguchi","character":"Dr. Ritsuko Akagi (voice)","img":null,"order":4},{"name":"Fumihiko Tachiki","character":"Gendo Ikari (voice)","img":null,"order":5},{"name":"Motomu Kiyokawa","character":"Kozo Fuyutsuki (voice)","img":null,"order":6},{"name":"Miki Nagasawa","character":"Maya Ibuki (voice)","img":null,"order":7},{"name":"Mugihito","character":"Keel Lorenz (voice)","img":null,"order":500},{"name":"Junko Iwao","character":"Hikari Horaki (voice)","img":null,"order":501},{"name":"Tetsuya Iwanaga","character":"Kensuke Aida (voice)","img":null,"order":503},{"name":"Tomokazu Seki","character":"Touji Suzuhara (voice)","img":null,"order":505},{"name":"Takehito Koyasu","character":"Shigeru Aoba (voice)","img":null,"order":505},{"name":"Koichi Yamadera","character":"Ryouji Kaji (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":506},{"name":"Hiro Yuuki","character":"Makoto Hyuuga (voice)","img":null,"order":508}],"tv:114868":[{"name":"Tomoyo Kurosawa","character":"G\u00f6ll (voice)","img":null,"order":1},{"name":"Wataru Takagi","character":"Zeus (voice)","img":null,"order":2},{"name":"Junichi Suwabe","character":"Hermes (voice)","img":null,"order":3},{"name":"Soma Saito","character":"Adam (voice)","img":null,"order":4},{"name":"Miyuki Sawashiro","character":"Narrator (voice)","img":null,"order":87},{"name":"Tatsuhisa Suzuki","character":"Shiva (voice)","img":null,"order":501},{"name":"Rie Tanaka","character":"Aphrodite (voice)","img":null,"order":502},{"name":"Yukihiro Nozuyama","character":"Heimdall (voice)","img":null,"order":516},{"name":"Koji Ishii","character":"Zhang Fei (voice)","img":null,"order":524},{"name":"Naoko Komatsu","character":"Eve (voice)","img":null,"order":525},{"name":"Taisuke Nakano","character":"Huginn (voice)","img":null,"order":531},{"name":"Daiki Hamano","character":"God (voice)","img":null,"order":532},{"name":"Chiko Mizumori","character":"Iori Miyamoto (voice)","img":null,"order":534},{"name":"Tomohiro Yamaguchi","character":"Man (voice)","img":null,"order":535},{"name":"Satoru Ito","character":"Spectator (voice)","img":null,"order":539}],"tv:207332":[{"name":"Tomokazu Sugita","character":"Taro Sakamoto (voice)","img":null,"order":0},{"name":"Nobunaga Shimazaki","character":"Shin Asakura (voice)","img":null,"order":1},{"name":"Ayane Sakura","character":"Lu Shaotang (voice)","img":null,"order":2},{"name":"Nao Toyama","character":"Aoi Sakamoto (voice)","img":null,"order":3},{"name":"Hina Kino","character":"Hana Sakamoto (voice)","img":null,"order":4},{"name":"Ryota Suzuki","character":"Heisuke Mashimo (voice)","img":null,"order":5},{"name":"Natsuki Hanae","character":"Yoichi Nagumo (voice)","img":null,"order":6},{"name":"Taku Yashiro","character":"Shishiba (voice)","img":null,"order":7},{"name":"Saori Hayami","character":"Osaragi (voice)","img":null,"order":8},{"name":"Hiroki Yasumoto","character":"Hyou (voice)","img":null,"order":9},{"name":"Hochu Otsuka","character":"Takamura (voice)","img":null,"order":10},{"name":"Daisuke Namikawa","character":"Kei Uzuki (voice)","img":null,"order":11},{"name":"Nobuhiko Okamoto","character":"Natsuki Seba (voice)","img":null,"order":500},{"name":"Yuhko Kaida","character":"Dump (voice)","img":null,"order":501},{"name":"Ryota Takeuchi","character":"Minimalist (voice)","img":null,"order":502}],"tv:249907":[{"name":"Yohei Azakami","character":"Xylo Forbartz (voice)","img":null,"order":0},{"name":"Mayu Iizuka","character":"Teoritta (voice)","img":null,"order":1},{"name":"Shizuka Ishigami","character":"Patouche Kivia (voice)","img":null,"order":2},{"name":"Shun Horie","character":"Dotta Luzlas (voice)","img":null,"order":3},{"name":"Shunichi Toki","character":"Venetim Leopold (voice)","img":null,"order":4},{"name":"Yoji Ueda","character":"Norgayu Senridge (voice)","img":null,"order":5},{"name":"Yoshitsugu Matsuoka","character":"Tatsuya (voice)","img":null,"order":6},{"name":"Jun Fukushima","character":"Tsav (voice)","img":null,"order":7},{"name":"Shoya Chiba","character":"Jace Pertilact (voice)","img":null,"order":8},{"name":"Yoko Hikasa","character":"Neely (voice)","img":null,"order":9}],"tv:120089":[{"name":"Takuya Eguchi","character":"Loid Forger (voice)","img":null,"order":0},{"name":"Atsumi Tanezaki","character":"Anya Forger (voice)","img":null,"order":1},{"name":"Saori Hayami","character":"Yor Forger (voice)","img":null,"order":2},{"name":"Kenichirou Matsuda","character":"Narration (voice)","img":null,"order":345},{"name":"Emiri Kato","character":"Agent M (voice)","img":null,"order":602},{"name":"Hiroki Goto","character":"Eden College Teacher (voice)","img":null,"order":611},{"name":"Hana Sato","character":"Onboard Announcement (voice)","img":null,"order":618},{"name":"Emiko Takeuchi","character":"Nurse (voice)","img":null,"order":643},{"name":"Mirei Kumagai","character":"Berlint General Hospital Doctor (voice)","img":null,"order":644},{"name":"Shoumaru Zouza","character":"Vides (voice)","img":null,"order":677},{"name":"Yuhko Kaida","character":"Sylvia Sherwood (voice)","img":null,"order":685},{"name":"Marie Ooi","character":"Gram Gretcher (voice)","img":null,"order":687},{"name":"Motoki Sakuma","character":"Wolson (voice)","img":null,"order":689},{"name":"Takaaki Torashima","character":"Secretary (voice)","img":null,"order":690},{"name":"Masafumi Kobatake","character":"Barkeeper (voice)","img":null,"order":694}],"tv:36243":[{"name":"Shintaro Asanuma","character":"'I' (Watashi) (voice)","img":null,"order":0},{"name":"Hiroyuki Yoshino","character":"Ozu (voice)","img":null,"order":1},{"name":"Keiji Fujiwara","character":"Higuchi (voice)","img":null,"order":2},{"name":"Yuhko Kaida","character":"Hanuki (voice)","img":null,"order":4},{"name":"Maaya Sakamoto","character":"Akashi (voice)","img":null,"order":5},{"name":"Junichi Suwabe","character":"Jogasaki (voice)","img":null,"order":6}],"tv:88803":[{"name":"Yuto Uemura","character":"Thorfinn (voice)","img":null,"order":0},{"name":"Naoya Uchida","character":"Askeladd (voice)","img":null,"order":1},{"name":"Shunsuke Takeuchi","character":"Einar (voice)","img":null,"order":2},{"name":"Kensho Ono","character":"Canute (voice)","img":null,"order":3},{"name":"Hiroki Yasumoto","character":"Bjorn (voice)","img":null,"order":4},{"name":"Akio Otsuka","character":"Thorkell (voice)","img":null,"order":5},{"name":"Yusuke Ohta","character":"Guard (voice)","img":null,"order":589},{"name":"Yoshiaki Hasegawa","character":"Scout of Thorkell's Troops (voice)","img":null,"order":596},{"name":"Takayuki Nakatsukasa","character":"Member of Askeladd's Mercenaries (voice)","img":null,"order":631},{"name":"Takuya Nakashima","character":"Retainer (voice)","img":null,"order":639},{"name":"Tsuguo Mogami","character":"Soldier of the English Army (voice)","img":null,"order":644},{"name":"Hiroki Goto","character":"Torgrim (voice)","img":null,"order":657},{"name":"Masato Niwa","character":"Wild Dog (voice)","img":null,"order":660},{"name":"Taito Ban","character":"Merchant (voice)","img":null,"order":662},{"name":"Shinya Takahashi","character":"Thegn (voice)","img":null,"order":678}],"tv:30669":[{"name":"Nozomu Sasaki","character":"Y\u016bsuke Urameshi (voice)","img":null,"order":1},{"name":"Shigeru Chiba","character":"Kazuma Kuwabara (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":2},{"name":"Megumi Ogata","character":"Kurama/Sh\u016bichi Minamino (voice)","img":null,"order":3},{"name":"Nobuyuki Hiyama","character":"Hiei (voice)","img":null,"order":4},{"name":"Yuri Amano","character":"Keiko Yukimura (voice)","img":null,"order":5},{"name":"Hisako Kyoda","character":"Genkai (voice)","img":null,"order":6},{"name":"Mayumi Tanaka","character":"Koenma (voice)","img":null,"order":7},{"name":"Ai Orikasa","character":"Shizuru Kuwabara (voice)","img":null,"order":8},{"name":"Tomomichi Nishimura","character":"Narration (voice)","img":null,"order":10},{"name":"Sanae Miyuki","character":"Botan","img":null,"order":10},{"name":"Yuri Shiratori","character":"Yukina (voice)","img":null,"order":12},{"name":"Katsumi Suzuki","character":"Toguro (voice)","img":null,"order":13},{"name":"Rokuro Naya","character":"Sensui Shinobu (voice)","img":null,"order":14}],"movie:1930":[{"name":"Andrew Garfield","character":"Spider-Man / Peter Parker","img":null,"order":0},{"name":"Emma Stone","character":"Gwen Stacy","img":null,"order":1},{"name":"Rhys Ifans","character":"The Lizard / Dr. Curt Connors","img":null,"order":2},{"name":"Denis Leary","character":"Captain Stacy","img":null,"order":3},{"name":"Martin Sheen","character":"Uncle Ben","img":null,"order":4},{"name":"Sally Field","character":"Aunt May","img":null,"order":5},{"name":"Irrfan Khan","character":"Rajit Ratha","img":null,"order":6},{"name":"Campbell Scott","character":"Richard Parker","img":null,"order":7},{"name":"Embeth Davidtz","character":"Mary Parker","img":null,"order":8},{"name":"Chris Zylka","character":"Flash Thompson","img":null,"order":9},{"name":"Max Charles","character":"Peter Parker (Age 4)","img":null,"order":10},{"name":"C. Thomas Howell","character":"Jack's Father","img":null,"order":11},{"name":"Jake Keiffer","character":"Jack","img":null,"order":12},{"name":"Kari Coleman","character":"Helen Stacy","img":null,"order":13},{"name":"Michael Barra","character":"Store Clerk","img":null,"order":14}],"movie:102382":[{"name":"Andrew Garfield","character":"Spider-Man / Peter Parker","img":null,"order":0},{"name":"Emma Stone","character":"Gwen Stacy","img":null,"order":1},{"name":"Jamie Foxx","character":"Electro / Max Dillon","img":null,"order":2},{"name":"Dane DeHaan","character":"Green Goblin / Harry Osborn","img":null,"order":3},{"name":"Colm Feore","character":"Donald Menken","img":null,"order":4},{"name":"Felicity Jones","character":"Felicia","img":null,"order":5},{"name":"Paul Giamatti","character":"Aleksei Sytsevich","img":null,"order":6},{"name":"Sally Field","character":"Aunt May","img":null,"order":7},{"name":"Embeth Davidtz","character":"Mary Parker","img":null,"order":8},{"name":"Campbell Scott","character":"Richard Parker","img":null,"order":9},{"name":"Marton Csokas","character":"Dr. Ashley Kafka","img":null,"order":10},{"name":"Louis Cancelmi","character":"Man in Black Suit","img":null,"order":11},{"name":"Max Charles","character":"Young Peter Parker","img":null,"order":12},{"name":"B.J. Novak","character":"Allistar Smythe","img":null,"order":13},{"name":"Sarah Gadon","character":"Kari","img":null,"order":14}],"movie:55931":[{"name":"Keanu Reeves","character":"Neo (\"Kid's Story\") (voice)","img":null,"order":0},{"name":"Carrie-Anne Moss","character":"Trinity (\"Kid's Story\" and \"A Detective Story\") (voice)","img":null,"order":1},{"name":"Clayton Watson","character":"The Kid (\"Kid's Story\") (voice)","img":null,"order":2},{"name":"Pamela Adlon","character":"Jue (\"Final Flight of the Osiris\") / Manabu (\"Beyond\") (voice)","img":"5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","order":3},{"name":"Melinda Clarke","character":"Alexa (\"Matriculated\") (voice)","img":null,"order":4},{"name":"John DiMaggio","character":"Crew Man (\"Final Flight of the Osiris\") / Kaiser (\"Program\") (voice)","img":null,"order":5},{"name":"Kevin Michael Richardson","character":"Thadeus (\"Final Flight of Osiris\") / Cop (\"Kid's Story\") / Agent #2 (\"World Record\")","img":null,"order":6},{"name":"Tom Kenny","character":"Operator (\"Final Flight of Osiris\")","img":null,"order":7},{"name":"Rick Gomez","character":"Pilot (\"Final Flight of Osiris\")","img":null,"order":8},{"name":"Tara Strong","character":"Crew Woman (\"Final Flight of Osiris\") / Nurse (\"World Record\") / Misha (\"Beyond\")","img":null,"order":9},{"name":"Bette Ford","character":"Old Woman (\"Final Flight of Osiris\")","img":null,"order":10},{"name":"Julia Fletcher","character":"The Instructor (\"The Second Renaissance Part I & II\") / Narrator (\"World Record\")","img":null,"order":11},{"name":"Dane A. Davis","character":"01 Versatran Spokesman (\"The Second Renaissance Part I & II\")","img":null,"order":12},{"name":"Debi Derryberry","character":"Kid (\"The Second Renaissance Part I & II\")","img":null,"order":13},{"name":"Jill Talley","character":"Mother (\"The Second Renaissance Part I & II\")","img":null,"order":14}],"movie:640146":[{"name":"Paul Rudd","character":"Scott Lang / Ant-Man","img":null,"order":0},{"name":"Evangeline Lilly","character":"Hope Van Dyne / The Wasp","img":null,"order":1},{"name":"Michael Douglas","character":"Dr. Hank Pym","img":null,"order":2},{"name":"Michelle Pfeiffer","character":"Janet Van Dyne","img":null,"order":3},{"name":"Jonathan Majors","character":"Kang the Conqueror","img":null,"order":4},{"name":"Kathryn Newton","character":"Cassie Lang","img":null,"order":5},{"name":"Corey Stoll","character":"M.O.D.O.K.","img":null,"order":6},{"name":"Bill Murray","character":"Lord Krylar","img":null,"order":7},{"name":"Katy O'Brian","character":"Jentorra","img":null,"order":8},{"name":"William Jackson Harper","character":"Quaz","img":null,"order":9},{"name":"Jamie Andrew Cutler","character":"Xolum","img":null,"order":10},{"name":"David Dastmalchian","character":"Veb (voice)","img":null,"order":11},{"name":"Randall Park","character":"Jimmy Woo","img":null,"order":12},{"name":"Mark Weinman","character":"Patrol Police Officer / On-Set M.O.D.O.K.","img":null,"order":13},{"name":"Ross Mullan","character":"Axia Restaurant Maitre D'","img":null,"order":14}],"movie:269650":[{"name":"Yuka Komatsu","character":"Deunan Knute (voice)","img":null,"order":0},{"name":"Junichi Suwabe","character":"Briareos Hecatonchires (voice)","img":null,"order":1},{"name":"Aoi Yuki","character":"Iris (voice)","img":null,"order":2},{"name":"Hiroki Takahashi","character":"Olson (voice)","img":null,"order":3},{"name":"Hiroki Touchi","character":"Talos (voice)","img":null,"order":4},{"name":"Kaori Nazuka","character":"Nyx (voice)","img":null,"order":5},{"name":"Tessyo Genda","character":"Two Horns (voice)","img":null,"order":6},{"name":"Katsunosuke Hori","character":"Mathews (voice)","img":null,"order":7},{"name":"Hisanori Yoshida","character":"(voice)","img":null,"order":8},{"name":"Shinichiro Kamio","character":"(voice)","img":null,"order":9},{"name":"Yoshiki Nakajima","character":"(voice)","img":null,"order":10},{"name":"Yuki Yonai","character":"(voice)","img":null,"order":11},{"name":"Mao Satonaka","character":"(voice)","img":null,"order":12}],"movie:572802":[{"name":"Jason Momoa","character":"Arthur","img":null,"order":0},{"name":"Patrick Wilson","character":"Orm","img":null,"order":1},{"name":"Yahya Abdul-Mateen II","character":"Black Manta","img":null,"order":2},{"name":"Randall Park","character":"Dr. Shin","img":null,"order":3},{"name":"Amber Heard","character":"Mera","img":null,"order":4},{"name":"Nicole Kidman","character":"Atlanna","img":null,"order":5},{"name":"Dolph Lundgren","character":"King Nereus","img":null,"order":6},{"name":"Temuera Morrison","character":"Tom","img":null,"order":7},{"name":"Lillah Indya Moore","character":"Karshon","img":null,"order":8},{"name":"Pilou Asb\u00e6k","character":"Kordax","img":null,"order":9},{"name":"Jani Zhao","character":"Stingray","img":null,"order":10},{"name":"Vincent Regan","character":"Atlan","img":null,"order":11},{"name":"Martin Short","character":"Kingfish (voice)","img":null,"order":12},{"name":"John Rhys-Davies","character":"Brine King (voice)","img":null,"order":13},{"name":"Natalia Safran","character":"Council Lady","img":null,"order":14}],"movie:24428":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":1},{"name":"Mark Ruffalo","character":"Bruce Banner / The Hulk","img":null,"order":2},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":3},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":4},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":5},{"name":"Tom Hiddleston","character":"Loki","img":null,"order":6},{"name":"Clark Gregg","character":"Agent Phil Coulson","img":null,"order":7},{"name":"Cobie Smulders","character":"Agent Maria Hill","img":null,"order":8},{"name":"Stellan Skarsg\u00e5rd","character":"Selvig","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":9},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":10},{"name":"Gwyneth Paltrow","character":"Pepper Potts","img":null,"order":11},{"name":"Paul Bettany","character":"Jarvis (voice)","img":null,"order":12},{"name":"Alexis Denisof","character":"The Other","img":null,"order":13},{"name":"Tina Benko","character":"NASA Scientist","img":null,"order":14}],"movie:257346":[{"name":"Miyuki Sawashiro","character":"Natasha Romanoff / Black Widow (voice)","img":null,"order":0},{"name":"Tessyo Genda","character":"Frank Castle / The Punisher (voice)","img":null,"order":1},{"name":"Hideaki Tezuka","character":"Nick Fury (voice)","img":null,"order":2},{"name":"Hiroki Touchi","character":"Elihas Starr (voice)","img":null,"order":3},{"name":"Ryuuzaburou Ootomo","character":"Cain (voice)","img":null,"order":4},{"name":"Junko Minagawa","character":"Maria Hill (voice)","img":null,"order":5},{"name":"Daisuke Namikawa","character":"Amadeus Cho (voice)","img":null,"order":6},{"name":"Masashi Sugawara","character":"Orion (voice)","img":null,"order":7},{"name":"Yuichi Karasuma","character":"The Hulk (voice)","img":null,"order":8},{"name":"Hisashi Izumi","character":"Ren (voice)","img":null,"order":9},{"name":"Keiji Fujiwara","character":"Tony Stark / Iron Man (voice)","img":null,"order":10},{"name":"Shuhei Sakaguchi","character":"Clint Barton / Hawkeye (voice)","img":null,"order":11}],"movie:99861":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":1},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":2},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":3},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":4},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":5},{"name":"James Spader","character":"Ultron (voice)","img":null,"order":6},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":7},{"name":"Don Cheadle","character":"James Rhodes / War Machine","img":null,"order":8},{"name":"Aaron Taylor-Johnson","character":"Pietro Maximoff / Quicksilver","img":null,"order":9},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":10},{"name":"Paul Bettany","character":"Jarvis / Vision","img":null,"order":11},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":12},{"name":"Anthony Mackie","character":"Sam Wilson / The Falcon","img":null,"order":13},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":14}],"movie:299536":[{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":0},{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":1},{"name":"Chris Hemsworth","character":"Thor","img":null,"order":2},{"name":"Josh Brolin","character":"Thanos","img":null,"order":3},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":4},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":5},{"name":"Don Cheadle","character":"James Rhodes / War Machine","img":null,"order":6},{"name":"Benedict Cumberbatch","character":"Doctor Strange","img":null,"order":7},{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":8},{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":9},{"name":"Zoe Salda\u00f1a","character":"Gamora","img":null,"order":10},{"name":"Karen Gillan","character":"Nebula","img":null,"order":11},{"name":"Tom Hiddleston","character":"Loki","img":null,"order":12},{"name":"Paul Bettany","character":"Vision","img":null,"order":13},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":14}],"movie:485942":[{"name":"Koichi Yamadera","character":"Batman / Bruce Wayne (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":0},{"name":"Wataru Takagi","character":"The Joker (voice)","img":null,"order":1},{"name":"Rie Kugimiya","character":"Harley Quinn / Harleen Quinzel (voice)","img":null,"order":2},{"name":"Ai Kakuma","character":"Catwoman / Selina Kyle (voice)","img":null,"order":3},{"name":"Hochu Otsuka","character":"Alfred Pennyworth (voice)","img":null,"order":4},{"name":"Daisuke Ono","character":"Nightwing / Dick Grayson (voice)","img":null,"order":5},{"name":"Akira Ishida","character":"Red Hood / Jason Todd (voice)","img":null,"order":6},{"name":"Kengo Kawanishi","character":"Red Robin / Tim Drake (voice)","img":null,"order":7},{"name":"Yuki Kaji","character":"Robin / Damian Wayne (voice)","img":null,"order":8},{"name":"Junichi Suwabe","character":"Deathstroke / Slade Wilson (voice)","img":null,"order":9},{"name":"Cho","character":"The Penguin / Oswald Cobblepot (voice)","img":null,"order":10},{"name":"Takehito Koyasu","character":"Gorilla Grodd (voice)","img":null,"order":11},{"name":"Toshiyuki Morikawa","character":"Two-Face / Harvey Dent (voice)","img":null,"order":12},{"name":"Atsuko Tanaka","character":"Poison Ivy / Pamela Isley (voice)","img":null,"order":13},{"name":"Kenta Miyake","character":"Bane (voice)","img":null,"order":14}],"movie:209112":[{"name":"Ben Affleck","character":"Bruce Wayne / Batman","img":null,"order":0},{"name":"Henry Cavill","character":"Clark Kent / Superman","img":null,"order":1},{"name":"Jesse Eisenberg","character":"Lex Luthor","img":null,"order":2},{"name":"Gal Gadot","character":"Diana Prince / Wonder Woman","img":null,"order":3},{"name":"Amy Adams","character":"Lois","img":null,"order":4},{"name":"Diane Lane","character":"Martha Kent","img":null,"order":5},{"name":"Laurence Fishburne","character":"Perry White","img":null,"order":6},{"name":"Jeremy Irons","character":"Alfred","img":null,"order":7},{"name":"Holly Hunter","character":"Senator Finch","img":null,"order":8},{"name":"Scoot McNairy","character":"Wallace Keefe","img":null,"order":9},{"name":"Callan Mulvey","character":"Anatoli Knyazev","img":null,"order":10},{"name":"Tao Okamoto","character":"Mercy Graves","img":null,"order":11},{"name":"Brandon Spink","character":"Young Bruce Wayne","img":null,"order":12},{"name":"Lauren Cohan","character":"Martha Wayne","img":null,"order":13},{"name":"Alan D. Purwin","character":"Wayne Industries Pilot","img":null,"order":14}],"movie:123025":[{"name":"Peter Weller","character":"Batman / Bruce Wayne (voice)","img":null,"order":0},{"name":"Ariel Winter","character":"Robin / Carrie Kelley (voice)","img":null,"order":1},{"name":"David Selby","character":"James Gordon (voice)","img":null,"order":2},{"name":"Wade Williams","character":"Harvey Dent / Two-Face (voice)","img":null,"order":3},{"name":"Michael McKean","character":"Dr. Bartholomew Wolper (voice)","img":null,"order":4},{"name":"Rob Paulsen","character":"Jeff Adams (voice)","img":null,"order":5},{"name":"Gary Anthony Williams","character":"Mutant Leader / Anchor Bill (voice)","img":null,"order":6},{"name":"Gary Anthony Sturgis","character":"Silk / Other Spike / Young Mutant (voice)","img":null,"order":7},{"name":"Paget Brewster","character":"Lana Lang (voice)","img":null,"order":8},{"name":"Carlos Alazraqui","character":"Hernando / TV Host (voice)","img":null,"order":9},{"name":"Dee Bradley Baker","character":"Don / Fat Mutant / Additional Voices (voice)","img":null,"order":10},{"name":"Maria Canals-Barrera","character":"Yindel (voice)","img":null,"order":11},{"name":"Cathy Cavadini","character":"Joanie / Woman with Hot Dog (voice)","img":null,"order":12},{"name":"Townsend Coleman","character":"Morrie / Civilian (voice)","img":null,"order":13},{"name":"Grey DeLisle","character":"Anchor Carla / Female Mutant (voice)","img":null,"order":14}],"movie:436270":[{"name":"Dwayne Johnson","character":"Black Adam / Teth Adam","img":null,"order":0},{"name":"Aldis Hodge","character":"Hawkman / Carter Hall","img":null,"order":1},{"name":"Noah Centineo","character":"Atom Smasher / Al Rothstein","img":null,"order":2},{"name":"Sarah Shahi","character":"Adrianna Tomaz","img":null,"order":3},{"name":"Quintessa Swindell","character":"Cyclone / Maxine Hunkel","img":null,"order":4},{"name":"Marwan Kenzari","character":"Ishmael / Sabbac / King Ahk-Ton","img":null,"order":5},{"name":"Mo Amer","character":"Karim","img":null,"order":6},{"name":"Bodhi Sabongui","character":"Amon Tomaz","img":null,"order":7},{"name":"Pierce Brosnan","character":"Dr. Fate / Kent Nelson","img":null,"order":8},{"name":"James Cusati-Moyer","character":"Samir","img":null,"order":9},{"name":"Jalon Christian","character":"Hurut","img":null,"order":10},{"name":"Benjamin Patterson","character":"Skinny Teth Adam","img":null,"order":11},{"name":"Odelya Halevi","character":"Shiruta","img":null,"order":12},{"name":"Uli Latukefu","character":"The Champion","img":null,"order":13},{"name":"Jennifer Holland","character":"Emilia Harcourt","img":null,"order":14}],"movie:284054":[{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":0},{"name":"Michael B. Jordan","character":"Erik Killmonger","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Nakia","img":null,"order":2},{"name":"Danai Gurira","character":"Okoye","img":null,"order":3},{"name":"Martin Freeman","character":"Everett K. Ross","img":null,"order":4},{"name":"Daniel Kaluuya","character":"W'Kabi","img":null,"order":5},{"name":"Letitia Wright","character":"Shuri","img":null,"order":6},{"name":"Winston Duke","character":"M'Baku","img":null,"order":7},{"name":"Sterling K. Brown","character":"N'Jobu","img":null,"order":8},{"name":"Angela Bassett","character":"Ramonda","img":null,"order":9},{"name":"Forest Whitaker","character":"Zuri","img":null,"order":10},{"name":"Andy Serkis","character":"Ulysses Klaue","img":null,"order":11},{"name":"Florence Kasumba","character":"Ayo","img":null,"order":12},{"name":"John Kani","character":"T'Chaka","img":null,"order":13},{"name":"David S. Lee","character":"Limbani","img":null,"order":14}],"movie:505642":[{"name":"Letitia Wright","character":"Shuri","img":null,"order":0},{"name":"Tenoch Huerta Mej\u00eda","character":"Namor","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Nakia","img":null,"order":2},{"name":"Danai Gurira","character":"Okoye","img":null,"order":3},{"name":"Winston Duke","character":"M'Baku","img":null,"order":4},{"name":"Angela Bassett","character":"Ramonda","img":null,"order":5},{"name":"Martin Freeman","character":"Everett Ross","img":null,"order":6},{"name":"Julia Louis-Dreyfus","character":"Valentina Allegra de Fontaine","img":null,"order":7},{"name":"Dominique Thorne","character":"Riri","img":null,"order":8},{"name":"Florence Kasumba","character":"Ayo","img":null,"order":9},{"name":"Michaela Coel","character":"Aneka","img":null,"order":10},{"name":"Alex Livinalli","character":"Attuma","img":null,"order":11},{"name":"Mabel Cadena","character":"Namora","img":null,"order":12},{"name":"Michael B. Jordan","character":"Killmonger","img":null,"order":13},{"name":"Isaach de Bankol\u00e9","character":"River Tribe Elder","img":null,"order":14}],"movie:409421":[{"name":"Takahiro Sakurai","character":"Killy (voice)","img":null,"order":0},{"name":"Kana Hanazawa","character":"Cibo (voice)","img":null,"order":1},{"name":"Sora Amamiya","character":"Zuru (voice)","img":null,"order":2},{"name":"Mamoru Miyano","character":"Sutezo (voice)","img":null,"order":3},{"name":"Aya Suzaki","character":"Tae (voice)","img":null,"order":4},{"name":"Nobunaga Shimazaki","character":"Fusata (voice)","img":null,"order":5},{"name":"Nanako Mori","character":"Fuku (voice)","img":null,"order":6},{"name":"Kazuhiro Yamaji","character":"Pop (voice)","img":null,"order":7}],"movie:17165":[{"name":"Masakazu Morita","character":"Ichigo Kurosaki (voice)","img":null,"order":0},{"name":"Fumiko Orikasa","character":"Rukia Kuchiki (voice)","img":null,"order":1},{"name":"Kentaro Ito","character":"Renji Abarai (voice)","img":null,"order":2},{"name":"Aya Hirano","character":"Homura (voice)","img":null,"order":3},{"name":"Hiroshi Kamiya","character":"Shizuku (voice)","img":null,"order":4},{"name":"Mitsuaki Madono","character":"Kon (voice)","img":null,"order":5},{"name":"Jun Fukuyama","character":"Yumichika Ayasegawa (voice)","img":null,"order":6},{"name":"Taro Yamaguchi","character":"Sasakibe Choujiro (voice)","img":null,"order":7},{"name":"Takako Honda","character":"Jinta Hanakari (voice)","img":null,"order":8},{"name":"Katsuyuki Konishi","character":"Shuuhei Hisagi (voice)","img":null,"order":9},{"name":"Rintaro Nishi","character":"Tetsuzaemon Iba (voice)","img":null,"order":10},{"name":"Tetsu Inada","character":"Sajin Komamura (voice)","img":null,"order":11},{"name":"Fumiko Orikasa","character":"Hisana Kuchiki (voice)","img":null,"order":12},{"name":"Rie Kugimiya","character":"Nemu Kurotsuchi (voice)","img":null,"order":13},{"name":"Hisayo Mochizuki","character":"Yachiru Kusajishi (voice)","img":null,"order":14}],"tv:73319":[{"name":"Ed Brown","character":"Self","img":null,"order":0},{"name":"Rosemarie Vega","character":"Self","img":null,"order":1},{"name":"Angela Deem","character":"Self","img":null,"order":2},{"name":"Michael Ilesanmi","character":"Self","img":null,"order":3}],"tv:90046":[],"tv:48866":[{"name":"Eliza Taylor","character":"Clarke Griffin","img":null,"order":0},{"name":"Bob Morley","character":"Bellamy Blake","img":null,"order":1},{"name":"Lindsey Morgan","character":"Raven Reyes","img":null,"order":2},{"name":"Marie Avgeropoulos","character":"Octavia Blake","img":null,"order":3},{"name":"Richard Harmon","character":"John Murphy","img":null,"order":5},{"name":"Luisa d'Oliveira","character":"Emori","img":null,"order":7},{"name":"Christopher Larkin","character":"Monty Green","img":null,"order":9},{"name":"Tati Gabrielle","character":"Gaia","img":null,"order":11},{"name":"Devon Bostick","character":"Jasper Jordan","img":null,"order":13},{"name":"Isaiah Washington","character":"Thelonious Jaha","img":null,"order":15},{"name":"Henry Ian Cusick","character":"Marcus Kane","img":null,"order":16},{"name":"Adina Porter","character":"Indra","img":null,"order":20},{"name":"Jarod Joseph","character":"Nathan Miller","img":null,"order":21},{"name":"Ricky Whittle","character":"Lincoln","img":null,"order":25},{"name":"Paige Turco","character":"Judge","img":null,"order":102}],"tv:11821":[{"name":"Bud Abbott","character":"Bud Abbott","img":null,"order":0},{"name":"Lou Costello","character":"Lou Costello","img":null,"order":1},{"name":"Joe Besser","character":"Stinky Davis","img":null,"order":500},{"name":"Hillary Brooke","character":"Hillary Brooke","img":null,"order":539},{"name":"Gordon Jones","character":"Mike Kelly","img":null,"order":540},{"name":"Sid Fields","character":"Sid Fields","img":null,"order":561},{"name":"Joe Kirk","character":"Mr. Bacciagalupe","img":null,"order":567},{"name":"Murray Leonard","character":"Doctor","img":null,"order":587},{"name":"Bobby Barber","character":"Tout","img":null,"order":614},{"name":"Joan Shawlee","character":"Marriage License Bureau Clerk","img":null,"order":634},{"name":"Dorothy Granger","character":"Arguing Wife","img":null,"order":642},{"name":"Milt Bronson","character":"Sam Speedy","img":null,"order":646},{"name":"Ray Walker","character":"Police Lt. Smith","img":null,"order":653},{"name":"Lucien Littlefield","character":"First Man","img":null,"order":658},{"name":"Veda Ann Borg","character":"Crazy Lady","img":null,"order":663}],"tv:43167":[{"name":"Yuki Kaji","character":"Arita Haruyuki","img":null,"order":0},{"name":"Sachika Misawa","character":"Kuroyuki-hime","img":null,"order":1},{"name":"Ayako Kawasumi","character":"Kakei Mihaya","img":null,"order":2},{"name":"Kenichi Suzumura","character":"Ash Roller","img":null,"order":3},{"name":"Aki Toyosaki","character":"Kurashima Chiyuri","img":null,"order":4},{"name":"Shintaro Asanuma","character":"Mayuzumi Takumu","img":null,"order":5},{"name":"Sanae Kobayashi","character":"Noumi Seiji","img":null,"order":6},{"name":"Rina Hidaka","character":"Kouzuki Yuniko","img":null,"order":7},{"name":"Haruka Tomatsu","character":"Wakamiya Megumi","img":null,"order":8}],"tv:114461":[{"name":"Rosario Dawson","character":"Ahsoka Tano","img":null,"order":0},{"name":"Natasha Liu Bordizzo","character":"Sabine Wren","img":null,"order":1},{"name":"Ivanna Sakhno","character":"Shin Hati","img":null,"order":2},{"name":"Ray Stevenson","character":"Baylan Skoll","img":null,"order":2},{"name":"Mary Elizabeth Winstead","character":"Hera Syndulla","img":null,"order":3},{"name":"Lars Mikkelsen","character":"Grand Admiral Thrawn","img":null,"order":4},{"name":"Eman Esfandi","character":"Ezra Bridger","img":null,"order":5},{"name":"Diana Lee Inosanto","character":"Morgan Elsbeth","img":null,"order":5},{"name":"Rory McCann","character":"Baylan Skoll","img":null,"order":6},{"name":"David Tennant","character":"Huyang (voice)","img":null,"order":6},{"name":"Hayden Christensen","character":"Anakin Skywalker","img":null,"order":7},{"name":"Paul Darnell","character":"Marrok","img":null,"order":512},{"name":"Chris Bartlett","character":"Home One Protocol Droid","img":null,"order":519},{"name":"Nican Robinson","character":"First Officer Vic Hawkins","img":null,"order":526},{"name":"Dawn Dininger","character":"Lieutenant Beyta","img":null,"order":546}],"tv:1433":[{"name":"Seth MacFarlane","character":"Stan Smith / Roger (voice)","img":null,"order":0},{"name":"Wendy Schaal","character":"Francine Smith (voice)","img":null,"order":1},{"name":"Rachael MacFarlane","character":"Hayley Smith (voice)","img":null,"order":2},{"name":"Scott Grimes","character":"Steve Smith (voice)","img":null,"order":3},{"name":"Dee Bradley Baker","character":"Klaus (voice)","img":null,"order":4},{"name":"Jeff Fischer","character":"Kidnapper (voice)","img":null,"order":204},{"name":"Mike Barker","character":"Terry Bates / Researcher (voice)","img":null,"order":1590},{"name":"Kevin Michael Richardson","character":"Rerun (voice)","img":null,"order":1824},{"name":"Mike Henry","character":"Jackson / Maitre D' (voice)","img":null,"order":1927},{"name":"Eddie Kaye Thomas","character":"Barry Robinson / Spike (voice)","img":null,"order":1967},{"name":"Patrick Stewart","character":"Avery Bullock as B (voice)","img":null,"order":2007},{"name":"Curtis Armstrong","character":"","img":null,"order":2028},{"name":"David Koechner","character":"Dick Reynolds (voice)","img":null,"order":2052},{"name":"Daisuke Suzuki","character":"Toshi Yoshida (voice)","img":null,"order":2054},{"name":"Richard Kind","character":"Car Salesman (voice)","img":null,"order":2057}],"tv:46639":[{"name":"Ricky Whittle","character":"Shadow Moon","img":null,"order":0},{"name":"Ian McShane","character":"Mr. Wednesday / God Odin","img":null,"order":1},{"name":"Emily Browning","character":"Laura Moon","img":null,"order":2},{"name":"Pablo Schreiber","character":"Mad Sweeney","img":null,"order":3},{"name":"Yetide Badaki","character":"Bilquis","img":null,"order":4},{"name":"Crispin Glover","character":"Mr. World","img":null,"order":5},{"name":"Bruce Langley","character":"Technical Boy","img":null,"order":6},{"name":"Omid Abtahi","character":"Salim","img":null,"order":7},{"name":"Kenzie Delo","character":"Herman","img":null,"order":9},{"name":"Demore Barnes","character":"Mr. Ibis","img":null,"order":611},{"name":"Herizen F. Guardiola","character":"Oshun","img":null,"order":612},{"name":"Peter Stormare","character":"Czernobog","img":null,"order":619},{"name":"Julia Sweeney","character":"Ann-Marie Hinzelmann","img":null,"order":620},{"name":"Eric Johnson","character":"Chad Mulligan","img":null,"order":621},{"name":"Lela Loren","character":"Marguerite Olsen","img":null,"order":624}],"tv:2985":[{"name":"Kevin Sorbo","character":"Dylan Hunt","img":null,"order":0},{"name":"Lisa Ryder","character":"Beka Valentine","img":null,"order":1},{"name":"Laura Bertram","character":"Trance Gemini","img":null,"order":2},{"name":"Gordon Michael Woolvett","character":"Seamus Harper","img":null,"order":3},{"name":"Lexa Doig","character":"Andromeda Ascendant","img":null,"order":4},{"name":"Brandy Ledford","character":"Doyle","img":null,"order":5},{"name":"Keith Hamilton Cobb","character":"Tyr Anasazi","img":null,"order":6},{"name":"Brent Stait","character":"Rev Bem","img":null,"order":7},{"name":"Steve Bacic","character":"Gaheris Rhade","img":null,"order":424},{"name":"Rik Kiviaho","character":"High Guard Captain Perim","img":null,"order":666},{"name":"Janyse Jaud","character":"Cavava (voice)","img":null,"order":677},{"name":"Robert Saunders","character":"Spirit of the Abyss","img":null,"order":689},{"name":"Jim Byrnes","character":"Virgil Vox","img":null,"order":775},{"name":"Nicholas Lea","character":"Tri-Lorn","img":null,"order":777},{"name":"Carmen Moore","character":"Tri-Jema","img":null,"order":813}],"tv:251":[{"name":"Dana Snyder","character":"Master Shake (voice)","img":null,"order":0},{"name":"Carey Means","character":"Frylock (voice)","img":null,"order":1},{"name":"Dave Willis","character":"Meatwad / Carl (voice)","img":null,"order":3},{"name":"Mike Schatz","character":"Emory (voice)","img":null,"order":6},{"name":"Andy Merrill","character":"Oglethorpe / Merle (voice)","img":null,"order":145},{"name":"Schoolly D","character":"Boost Mobile Phone (voice)","img":null,"order":333},{"name":"Matt Maiellaro","character":"Err / Cybernetic Ghost of Christmas Past (voice)","img":null,"order":396},{"name":"C. Martin Croker","character":"Birds (voice)","img":null,"order":455},{"name":"H. Jon Benjamin","character":"Don Shake","img":null,"order":576},{"name":"MC Chris","character":"Little Brittle (voice)","img":null,"order":617},{"name":"George Lowe","character":"George Lowe (voice)","img":null,"order":642},{"name":"Scott Hilley","character":"George Washington (voice)","img":null,"order":642},{"name":"Eugene Mirman","character":"Dr. Eugene Mirman (voice)","img":null,"order":660},{"name":"Edward Hastings","character":"Panda Furry (voice)","img":null,"order":661},{"name":"Nick Ingkatanuwat","character":"Tour Guide (voice)","img":null,"order":670}],"tv:86034":[{"name":"Toshinari Fukamachi","character":"Hajime Nagumo (voice)","img":null,"order":0},{"name":"Yuuki Kuwahara","character":"Yue (voice)","img":null,"order":1},{"name":"Minami Takahashi","character":"Shea Haulia (voice)","img":null,"order":2},{"name":"Yoko Hikasa","character":"Tio Klarus (voice)","img":null,"order":3},{"name":"Saori Onishi","character":"Kaori Shirasaki (voice)","img":null,"order":4},{"name":"Yumiri Hanamori","character":"Shizuku Yaegashi (voice)","img":null,"order":5},{"name":"Ai Kakuma","character":"Aiko Hatayama (voice)","img":null,"order":6},{"name":"Yui Ogura","character":"Myu (voice)","img":null,"order":7},{"name":"Noriko Shibasaki","character":"Liliana S. B. Heiligh (voice)","img":null,"order":8},{"name":"Katsuyuki Konishi","character":"Freid Bagwa (voice)","img":null,"order":10},{"name":"Makoto Ishii","character":"Mikhail (voice)","img":null,"order":11},{"name":"Hiroshi Watanabe","character":"Haulia Yao (voice)","img":null,"order":12},{"name":"Rina Sato","character":"Nun (voice)","img":null,"order":269},{"name":"Tetsuya Kakihara","character":"K\u014dki Amanogawa (voice)","img":null,"order":563},{"name":"Yasuhiro Mamiya","character":"Meld Loggins (voice)","img":null,"order":564}],"tv:1412":[{"name":"Stephen Amell","character":"Oliver Queen / Green Arrow","img":null,"order":0},{"name":"David Ramsey","character":"John Diggle / Spartan","img":null,"order":1},{"name":"Rick Gonzalez","character":"Rene Ramirez / Wild Dog","img":null,"order":4},{"name":"Susanna Thompson","character":"Moira Queen","img":null,"order":5},{"name":"Emily Bett Rickards","character":"Felicity Smoak","img":null,"order":13},{"name":"Paul Blackthorne","character":"Quentin Lance","img":null,"order":14},{"name":"Colton Haynes","character":"Roy Harper / Arsenal","img":null,"order":19},{"name":"John Barrowman","character":"Malcolm Merlyn","img":null,"order":26},{"name":"Echo Kellum","character":"Curtis Holt","img":null,"order":27},{"name":"Katie Cassidy","character":"Black Siren","img":null,"order":34},{"name":"Willa Holland","character":"Thea Queen / Speedy","img":null,"order":96},{"name":"Juliana Harkavy","character":"Dinah Drake / Black Canary","img":null,"order":223},{"name":"Manu Bennett","character":"Slade Wilson / Deathstroke","img":null,"order":279},{"name":"Audrey Marie Anderson","character":"Lyla Michaels","img":null,"order":1444},{"name":"Keri Adams","character":"Bethany Snow","img":null,"order":1589}],"tv:82452":[{"name":"Gordon Cormier","character":"Aang","img":null,"order":0},{"name":"Kiawentiio","character":"Katara","img":null,"order":1},{"name":"Ian Ousley","character":"Sokka","img":null,"order":2},{"name":"Dallas Liu","character":"Zuko","img":null,"order":3},{"name":"Paul Sun-Hyung Lee","character":"Uncle Iroh","img":null,"order":4},{"name":"Miyako","character":"Toph Beifong","img":null,"order":5},{"name":"Ken Leung","character":"Commander Zhao","img":null,"order":6},{"name":"Daniel Dae Kim","character":"Fire Lord Ozai","img":null,"order":7},{"name":"Ruy Iskandar","character":"Lieutenant Jee","img":null,"order":504},{"name":"Maria Zhang","character":"Suki","img":null,"order":523},{"name":"Thalia Tran","character":"Mai","img":null,"order":532},{"name":"Momona Tamada","character":"Ty Lee","img":null,"order":533},{"name":"Matthew Yang King","character":"Appa/Momo(voice)","img":null,"order":568},{"name":"Elizabeth Yu","character":"Azula","img":null,"order":575},{"name":"Dee Bradley Baker","character":"Appa/Momo(voice)","img":null,"order":629}],"tv:284833":[{"name":"Saheli Khan","character":"Pavi (voice)","img":null,"order":0},{"name":"Aishwarya 'Aishu' Devan","character":"Nisha (voice)","img":null,"order":1},{"name":"Major Curda","character":"Jae (voice)","img":null,"order":2},{"name":"Akshay Khanna","character":"Karthik (voice)","img":null,"order":3},{"name":"Sakina Jaffrey","character":"Agam (voice)","img":null,"order":4},{"name":"Eric Bauza","character":"Jin-woo / Pich / One Guy in the Crowd / Crowd / Airbenders / Firebenders (voice)","img":null,"order":502},{"name":"Tobit Raphael","character":"Ulan / Bending Cadet #1 / Bending Cadets (voice)","img":null,"order":509},{"name":"Dee Bradley Baker","character":"Geet / Ruhi (voice)","img":null,"order":510},{"name":"Darren Barnet","character":"Daemin (voice)","img":null,"order":511},{"name":"Clyde Kusatsu","character":"Commander Bountan / Crowd / Airbenders / Firebenders (voice)","img":null,"order":513},{"name":"Sarah Anne","character":"Fang / Bending Cadet #5 / Elloran Girl (voice)","img":null,"order":514},{"name":"Brycen Taylor Hall","character":"Ping (voice)","img":null,"order":515},{"name":"Xanthe Huynh","character":"Lan / Elloran Citizens (voice)","img":null,"order":516},{"name":"Kimberly Woods","character":"Lucky Cadet / Bending Cadet #2 / Bending Cadets (voice)","img":null,"order":519},{"name":"Zehra Fazal","character":"Bending Cadet #3 / Bending Cadet #4 / Masked Elloran Woman (voice)","img":null,"order":519}],"tv:15804":[{"name":"Diedrich Bader","character":"Batman (voice)","img":null,"order":0},{"name":"James Arnold Taylor","character":"Green Arrow / Jace (voice)","img":null,"order":523},{"name":"John DiMaggio","character":"Aquaman / Mr. Freeze (voice)","img":null,"order":549},{"name":"Jeff Bennett","character":"Buddy / OMAC (voice)","img":null,"order":553},{"name":"Tara Strong","character":"Mary/Billy Batson (voice)","img":null,"order":606},{"name":"Jason Marsden","character":"Scooby-Doo Robin (voice)","img":null,"order":628},{"name":"Tom Kenny","character":"Babyface / Plastic Man (voice)","img":null,"order":648},{"name":"Dee Bradley Baker","character":"Felix Faust / Fluke (voice)","img":null,"order":665},{"name":"Kevin Michael Richardson","character":"Lex Luthor (voice)","img":null,"order":667},{"name":"Greg Ellis","character":"Cavalier / Computer Voice / Nuclear Engineer (voice)","img":null,"order":673},{"name":"Corey Burton","character":"Silver Cyclone (voice)","img":null,"order":682},{"name":"Nicholas Guest","character":"Question (voice)","img":null,"order":722},{"name":"Will Friedle","character":"Blue Beetle (voice)","img":null,"order":733},{"name":"Grey DeLisle","character":"Daphne (voice)","img":null,"order":736},{"name":"Tom Everett Scott","character":"Booster Gold (voice)","img":null,"order":759}],"tv:89247":[{"name":"Ruby Rose","character":"Kate Kane / Batwoman","img":null,"order":1},{"name":"Javicia Leslie","character":"Ryan Wilder / Batwoman","img":null,"order":2},{"name":"Rachel Skarsten","character":"Alice / Beth Kane","img":null,"order":3},{"name":"Meagan Tandy","character":"Sophie Moore","img":null,"order":4},{"name":"Nicole Kang","character":"Mary Hamilton","img":null,"order":5},{"name":"Camrus Johnson","character":"Luke Fox","img":null,"order":6},{"name":"Dougray Scott","character":"Jacob Kane","img":null,"order":7},{"name":"Nick Creegan","character":"Marquis Jet","img":null,"order":8},{"name":"Robin Givens","character":"Jada Jet","img":null,"order":9},{"name":"Elizabeth Anweis","character":"Catherine Hamilton-Kane","img":null,"order":10},{"name":"Christina Wolfe","character":"Julia Pennyworth","img":null,"order":679},{"name":"Rachel Maddow","character":"Vesper Fairchild (voice)","img":null,"order":836},{"name":"Sam Littlefield","character":"Mouse","img":null,"order":841},{"name":"Nathan Owens","character":"Ocean","img":null,"order":842},{"name":"Allison Riley","character":"Dana Dewitt","img":null,"order":859}],"tv:74204":[{"name":"Nick Kroll","character":"Nick Birch / Maury the Hormone Monster / Coach Steve / Lola Skumpy / Rick the Hormone Monster (voice)","img":null,"order":0},{"name":"John Mulaney","character":"Andrew Glouberman (voice)","img":null,"order":1},{"name":"Jessi Klein","character":"Jessi Glaser (voice)","img":null,"order":2},{"name":"Jason Mantzoukas","character":"Jay Bilzerian (voice)","img":null,"order":4},{"name":"Maya Rudolph","character":"Diane Birch / Connie the Hormone Monstress (voice)","img":null,"order":5},{"name":"Fred Armisen","character":"Elliot Birch (voice)","img":null,"order":6},{"name":"Jordan Peele","character":"Ghost of Duke Ellington (voice)","img":null,"order":7},{"name":"Ayo Edebiri","character":"Missy Foreman-Greenwald (voice)","img":null,"order":91},{"name":"Andrew Rannells","character":"Leander / Matthew MacDell (voice)","img":null,"order":136},{"name":"Jenny Slate","character":"Pharmacist / Caitlin (voice)","img":null,"order":149},{"name":"Joe Wengert","character":"Caleb / Go-Gurt Thief / Teacher (voice)","img":null,"order":578},{"name":"Paula Pell","character":"(voice)","img":null,"order":622},{"name":"Neil Casey","character":"Detective Dumont (voice)","img":null,"order":624},{"name":"June Diane Raphael","character":"","img":null,"order":639},{"name":"Richard Kind","character":"","img":null,"order":674}],"tv:71663":[{"name":"Cress Williams","character":"Jefferson Pierce / Black Lightning","img":null,"order":0},{"name":"Nafessa Williams","character":"Anissa Pierce / Thunder / Blackbird","img":null,"order":1},{"name":"China Anne McClain","character":"Jennifer Pierce / Lightning","img":null,"order":2},{"name":"Christine Adams","character":"Lynn Pierce","img":null,"order":3},{"name":"Marvin Jones III","character":"Tobias Whale","img":null,"order":4},{"name":"James Remar","character":"Peter Gambi","img":null,"order":5},{"name":"Jordan Calloway","character":"Khalil Payne / Painkiller","img":null,"order":6},{"name":"Chantal Thuy","character":"Grace Choi","img":null,"order":7},{"name":"Damon Gupton","character":"Bill Henderson (voice)","img":null,"order":211},{"name":"Clifton Powell","character":"Reverend Jeremiah Holt","img":null,"order":730},{"name":"Bill Duke","character":"Agent Percy Odell","img":null,"order":801},{"name":"Jennifer Riker","character":"Dr. Helga Jace","img":null,"order":802},{"name":"Christopher Ammanuel","character":"Baron / TC","img":null,"order":818},{"name":"William Catlett","character":"Latavious 'Lala' Johnson","img":null,"order":853},{"name":"Jahking Guillory","character":"Brandon","img":null,"order":855}],"movie:1574":[{"name":"Ren\u00e9e Zellweger","character":"Roxie Hart","img":null,"order":0},{"name":"Catherine Zeta-Jones","character":"Velma Kelly","img":null,"order":1},{"name":"Richard Gere","character":"Billy Flynn","img":null,"order":2},{"name":"Queen Latifah","character":"Matron Mama Morton","img":null,"order":3},{"name":"John C. Reilly","character":"Amos Hart","img":null,"order":4},{"name":"Colm Feore","character":"Harrison","img":null,"order":5},{"name":"Dominic West","character":"Fred Casely","img":null,"order":6},{"name":"Christine Baranski","character":"Mary Sunshine","img":null,"order":7},{"name":"Ekaterina Chtchelkanova","character":"Katalin Helinski \"Hunyak\"","img":null,"order":8},{"name":"Lucy Liu","character":"Kitty Baxter","img":null,"order":9},{"name":"Taye Diggs","character":"Bandleader","img":null,"order":10},{"name":"Mya","character":"Mona","img":null,"order":11},{"name":"Deidre Goodwin","character":"June","img":null,"order":12},{"name":"Denise Faye","character":"Annie","img":null,"order":13},{"name":"Susan Misner","character":"Liz","img":null,"order":14}],"movie:220289":[{"name":"Emily Baldoni","character":"Em","img":null,"order":0},{"name":"Maury Sterling","character":"Kevin","img":null,"order":1},{"name":"Nicholas Brendon","character":"Mike","img":null,"order":2},{"name":"Lauren Maher","character":"Laurie","img":null,"order":3},{"name":"Elizabeth Gracen","character":"Beth","img":null,"order":4},{"name":"Hugo Armstrong","character":"Hugh","img":null,"order":5},{"name":"Lorene Scafaria","character":"Lee","img":null,"order":6},{"name":"Alex Manugian","character":"Amir","img":null,"order":7},{"name":"Kelly Donovan","character":"Extra","img":null,"order":8},{"name":"Aqueela Zoll","character":"Extra","img":null,"order":9},{"name":"Mark Ballou","character":"Extra","img":null,"order":10},{"name":"Alexis Boozer Sterling","character":"Extra","img":null,"order":11}],"movie:399057":[{"name":"Colin Farrell","character":"Steven Murphy","img":null,"order":0},{"name":"Nicole Kidman","character":"Anna Murphy","img":null,"order":1},{"name":"Barry Keoghan","character":"Martin Lang","img":null,"order":2},{"name":"Raffey Cassidy","character":"Kim Murphy","img":null,"order":3},{"name":"Sunny Suljic","character":"Bob Murphy","img":null,"order":4},{"name":"Bill Camp","character":"Matthew Williams","img":null,"order":5},{"name":"Alicia Silverstone","character":"Martin's Mother","img":null,"order":6},{"name":"Herb Caillouet","character":"Ed Thompson (Hospital Director)","img":null,"order":7},{"name":"Barry G. Bernson","character":"Dr. Larry Banks","img":null,"order":8},{"name":"Denise Dal Vera","character":"Mary Williams","img":null,"order":9},{"name":"Drew Logan","character":"Principal","img":null,"order":10},{"name":"Michael Trester","character":"Elderly Man","img":null,"order":11},{"name":"Anita Farmer Bergman","character":"Nurse (uncredited)","img":null,"order":12},{"name":"Lea Hutton Beasmore","character":"Conference Guest (uncredited)","img":null,"order":13},{"name":"Dylan Keith Adams","character":"Doctor at Conference and Event Dinner (uncredited)","img":null,"order":14}],"movie:430214":[{"name":"Gen Hoshino","character":"Senpai (voice)","img":null,"order":0},{"name":"Kana Hanazawa","character":"The Girl with Black Hair (voice)","img":null,"order":1},{"name":"Ami Koshimizu","character":"Naoko (voice)","img":null,"order":2},{"name":"Aoi Yuki","character":"Princess Daruma (voice)","img":null,"order":3},{"name":"Hiroshi Kamiya","character":"Director of School Festival Operations (voice)","img":null,"order":4},{"name":"Chikara Honda","character":"Bookstore Shopkeeper Gabi (voice)","img":null,"order":5},{"name":"Hiroyuki Yoshino","character":"God of the Old Books Market (voice)","img":null,"order":6},{"name":"Junichi Suwabe","character":"Nise-J\u014dgasaki (voice)","img":null,"order":7},{"name":"Kazuhiro Yamaji","character":"Todo (voice)","img":null,"order":8},{"name":"Kazuya Nakai","character":"Seitar\u014d Higuchi (voice)","img":null,"order":9},{"name":"Mugihito","character":"Ri Haku (voice)","img":null,"order":10},{"name":"Nobuyuki Hiyama","character":"Johnny (voice)","img":null,"order":11},{"name":"Ryuji Akiyama","character":"Don Underwear (voice)","img":null,"order":12},{"name":"Seiko Niizuma","character":"Kiko (voice)","img":null,"order":13},{"name":"S\u014dichi Nakaoka","character":"K\u014dsaka (voice)","img":null,"order":14}],"movie:1541":[{"name":"Susan Sarandon","character":"Louise","img":null,"order":0},{"name":"Geena Davis","character":"Thelma","img":null,"order":1},{"name":"Harvey Keitel","character":"Hal","img":null,"order":2},{"name":"Michael Madsen","character":"Jimmy","img":null,"order":3},{"name":"Christopher McDonald","character":"Darryl","img":null,"order":4},{"name":"Stephen Tobolowsky","character":"Max","img":"uL8yfcm0dCFYFQt052kczVQVvac.jpg","order":5},{"name":"Brad Pitt","character":"J.D.","img":null,"order":6},{"name":"Timothy Carhart","character":"Harlan","img":null,"order":7},{"name":"Lucinda Jenney","character":"Lena, the Waitress","img":null,"order":8},{"name":"Jason Beghe","character":"State Trooper","img":"hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","order":9},{"name":"Sonny Carl Davis","character":"Albert","img":null,"order":10},{"name":"Ken Swofford","character":"Major","img":null,"order":11},{"name":"Shelly Desai","character":"East Indian Motel Clerk","img":null,"order":12},{"name":"Carol Mansell","character":"Waitress","img":null,"order":13},{"name":"Stephen Polk","character":"Surveilance Man","img":null,"order":14}],"movie:5038":[{"name":"Scarlett Johansson","character":"Cristina","img":null,"order":0},{"name":"Rebecca Hall","character":"Vicky","img":null,"order":1},{"name":"Javier Bardem","character":"Juan Antonio Gonzalo","img":null,"order":2},{"name":"Pen\u00e9lope Cruz","character":"Mar\u00eda Elena","img":null,"order":3},{"name":"Christopher Evan Welch","character":"Narrator (voice)","img":null,"order":4},{"name":"Chris Messina","character":"Doug","img":null,"order":5},{"name":"Patricia Clarkson","character":"Judy Nash","img":null,"order":6},{"name":"Kevin Dunn","character":"Mark Nash","img":null,"order":7},{"name":"Julio Perill\u00e1n","character":"Charles","img":null,"order":8},{"name":"Ricard Salom","character":"Art Gallery Guest","img":null,"order":9},{"name":"Maurice Sonnenberg","character":"Art Gallery Guest","img":null,"order":10},{"name":"Manel Barcel\u00f3","character":"Doctor","img":null,"order":11},{"name":"Josep Maria Dom\u00e8nech","character":"Julio","img":null,"order":12},{"name":"Emilio de Benito","character":"Guitarist in Asturias","img":null,"order":13},{"name":"Jaume Montan\u00e9","character":"Juan Antonio's Friend","img":null,"order":14}],"tv:108255":[{"name":"Nicholas Ralph","character":"James Herriot","img":null,"order":0},{"name":"Samuel West","character":"Siegfried Farnon","img":null,"order":1},{"name":"Anna Madeley","character":"Mrs Hall","img":null,"order":2},{"name":"Callum Woodhouse","character":"Tristan Farnon","img":null,"order":3},{"name":"Rachel Shenton","character":"Helen Herriot","img":null,"order":4},{"name":"James Anthony-Rose","character":"Richard Carmody","img":null,"order":500},{"name":"Mollie Winnard","character":"Maggie","img":null,"order":528},{"name":"Maimie McCoy","character":"Dorothy","img":null,"order":530},{"name":"Drew Cain","character":"James Herriot Senior","img":null,"order":535},{"name":"Will Thorp","character":"Gerald Hammond","img":null,"order":539},{"name":"Matthew Lewis","character":"Hugh Hulton","img":null,"order":552},{"name":"Patricia Hodge","character":"Mrs. Pumphrey","img":null,"order":553},{"name":"Imogen Clawson","character":"Jenny Alderson","img":null,"order":556},{"name":"Tony Pitts","character":"Richard Alderson","img":null,"order":558},{"name":"Gaia Wise","character":"Charlotte Beauvoir","img":null,"order":582}],"tv:88510":[{"name":"Daisy Haggard","character":"Miri Matteson","img":null,"order":0},{"name":"Geraldine James","character":"Caroline Matteson","img":null,"order":1},{"name":"Richard Durden","character":"Oscar Matteson","img":null,"order":2},{"name":"Jo Martin","character":"Janice","img":null,"order":3},{"name":"Adeel Akhtar","character":"Billy","img":null,"order":4},{"name":"Jamie Michie","character":"Dom","img":null,"order":5},{"name":"Christine Bottomley","character":"Mandy","img":null,"order":6},{"name":"Frank Feys","character":"Samuel","img":null,"order":502},{"name":"Liam Williams","character":"Nathan","img":null,"order":504},{"name":"Imogen Gurney","character":"Lara","img":null,"order":505},{"name":"Juliet Cowan","character":"Tina","img":null,"order":510},{"name":"Lizzy McInnerny","character":"Norah Boback","img":null,"order":515},{"name":"Adrian Edmondson","character":"John Boback","img":null,"order":516},{"name":"Keir Charles","character":"Mark","img":null,"order":520},{"name":"Hebe Bartlett","character":"Cerys","img":null,"order":522}],"tv:202851":[{"name":"Jack Rooke","character":"Narrator (voice)","img":null,"order":0},{"name":"Dylan Llewellyn","character":"Jack","img":null,"order":1},{"name":"Camille Coduri","character":"Peggy","img":null,"order":2},{"name":"Jon Pointing","character":"Danny","img":null,"order":3},{"name":"Katy Wix","character":"Jules","img":null,"order":4},{"name":"Izuka Hoyle","character":"Corinne","img":null,"order":5},{"name":"Olisa Odele","character":"Yemi","img":null,"order":6},{"name":"Harriet Webb","character":"Shannon","img":null,"order":7},{"name":"Annette Badland","character":"Nanny Bingo","img":null,"order":8},{"name":"Robert Gilbert","character":"Tim","img":null,"order":501},{"name":"Sheila Reid","character":"Iris","img":null,"order":502},{"name":"Lucia Keskin","character":"Kelly","img":null,"order":503},{"name":"Shane Zaza","character":"Tariq","img":null,"order":504},{"name":"Callum Mardy","character":"Ash","img":null,"order":505},{"name":"Ian Burfield","character":"Lance (voice)","img":null,"order":506}],"tv:39793":[{"name":"Vanessa Redgrave","character":"Jennifer Worth (voice)","img":null,"order":0},{"name":"Jenny Agutter","character":"Sister Julienne","img":null,"order":2},{"name":"Judy Parfitt","character":"Sister Monica Joan","img":null,"order":3},{"name":"Linda Bassett","character":"Phyllis Crane","img":null,"order":12},{"name":"Stephen McGann","character":"Dr Patrick Turner","img":null,"order":14},{"name":"Alice Brown","character":"Angela Turner","img":null,"order":23},{"name":"Laura Main","character":"Nurse Shelagh Turner","img":null,"order":24},{"name":"Cliff Parisi","character":"Fred Buckle","img":null,"order":26},{"name":"Georgie Glen","character":"Miss Millicent Higgins","img":null,"order":28},{"name":"Victoria Yeates","character":"Sister Winifred","img":null,"order":35},{"name":"Annabelle Apsion","character":"Violet Buckle","img":null,"order":87},{"name":"Max Macmillan","character":"Timothy Turner","img":null,"order":94},{"name":"Helen George","character":"Nurse Trixie Aylward","img":null,"order":102},{"name":"Zephryn Taitte","character":"Cyril Robinson","img":null,"order":104},{"name":"April Rae Hoang","character":"May Tang","img":null,"order":237}],"tv:76148":[{"name":"Saoirse-Monica Jackson","character":"Erin Quinn","img":null,"order":0},{"name":"Louisa Harland","character":"Orla McCool","img":null,"order":1},{"name":"Nicola Coughlan","character":"Clare Devlin","img":null,"order":2},{"name":"Jamie Lee O'Donnell","character":"Michelle Mallon","img":null,"order":3},{"name":"Dylan Llewellyn","character":"James Maguire","img":null,"order":4},{"name":"Tara Lynne O'Neill","character":"Ma Mary Quinn","img":null,"order":5},{"name":"Tommy Tiernan","character":"Da Gerry Quinn","img":null,"order":6},{"name":"Kathy Kiera Clarke","character":"Aunt Sarah","img":null,"order":7},{"name":"Ian McElhinney","character":"Granda Joe","img":null,"order":8},{"name":"Siobh\u00e1n McSweeney","character":"Sister Michael","img":null,"order":9},{"name":"Leah O'Rourke","character":"Jenny Joyce","img":null,"order":10},{"name":"Donna Traynor","character":"Donna Traynor","img":null,"order":555},{"name":"Beccy Henderson","character":"Aisling","img":null,"order":558},{"name":"Claire Rafferty","character":"Miss Mooney","img":null,"order":559},{"name":"Phillipa Dunne","character":"Geraldine Devlin","img":null,"order":572}],"tv:693":[{"name":"Teri Hatcher","character":"Susan Mayer","img":null,"order":0},{"name":"Felicity Huffman","character":"Lynette Scavo","img":null,"order":1},{"name":"Marcia Cross","character":"Bree Van de Kamp","img":null,"order":2},{"name":"Eva Longoria","character":"Gabrielle Solis","img":null,"order":3},{"name":"Ricardo Chavira","character":"Carlos Solis","img":null,"order":4},{"name":"Doug Savant","character":"Tom Scavo","img":null,"order":5},{"name":"Nicollette Sheridan","character":"Edie Britt","img":null,"order":6},{"name":"Mark Moses","character":"Paul Young","img":null,"order":7},{"name":"James Denton","character":"Mike Delfino","img":null,"order":8},{"name":"Brenda Strong","character":"Mary Alice Young","img":null,"order":10},{"name":"Kyle MacLachlan","character":"Orson Hodge","img":null,"order":14},{"name":"Shawn Pyfrom","character":"Andrew Van de Kamp","img":null,"order":19},{"name":"Andrea Bowen","character":"Julie Mayer","img":null,"order":21},{"name":"Kathryn Joosten","character":"Karen McCluskey","img":null,"order":25},{"name":"Joy Jorgensen","character":"Danielle Van de Kamp","img":null,"order":717}],"tv:203857":[{"name":"Keri Russell","character":"Ambassador Kate Wyler","img":null,"order":0},{"name":"Rufus Sewell","character":"Hal Wyler","img":null,"order":1},{"name":"David Gyasi","character":"Foreign Secretary Austin Dennison","img":null,"order":2},{"name":"Ali Ahn","character":"Eidra Park","img":null,"order":3},{"name":"Rory Kinnear","character":"Prime Minister Nicol Trowbridge","img":null,"order":4},{"name":"Ato Essandoh","character":"Stuart Hayford","img":null,"order":5},{"name":"Allison Janney","character":"Grace Penn","img":null,"order":7},{"name":"Nana Mensah","character":"Billie Appiah","img":null,"order":8},{"name":"Celia Imrie","character":"Meg Roylin","img":null,"order":500},{"name":"Penny Downie","character":"Frances Munning","img":null,"order":505},{"name":"Adam Silver","character":"Howard","img":null,"order":506},{"name":"Tim Delap","character":"Byron","img":null,"order":507},{"name":"Kenichiro Thomson","character":"Martin","img":null,"order":508},{"name":"Georgie Henley","character":"Pensy","img":null,"order":509},{"name":"Graham Miller","character":"Neil Barrow","img":null,"order":572}],"tv:66134":[{"name":"Keeley Hawes","character":"Louisa Durrell","img":null,"order":0},{"name":"Josh O'Connor","character":"Lawrence Durrell","img":null,"order":1},{"name":"Milo Parker","character":"Gerald Durrell","img":null,"order":2},{"name":"Daisy Waterstone","character":"Margo Durrell","img":null,"order":3},{"name":"Callum Woodhouse","character":"Leslie Durrell","img":null,"order":4},{"name":"Alexis Georgoulis","character":"Spiros Halikiopoulos","img":null,"order":5},{"name":"Yorgos Karamihos","character":"Theo Stephanides","img":null,"order":6},{"name":"Anna Savva","character":"Lugaretzia","img":null,"order":8},{"name":"Lucy Black","character":"Florence Petridis","img":null,"order":79},{"name":"Ben Hall","character":"Donald","img":null,"order":525},{"name":"Nikorestis Chaniotakis","character":"Pavlos the Monk","img":null,"order":553},{"name":"Elli Tringou","character":"Daphne Likourgou","img":null,"order":553},{"name":"Ulric von der Esch","character":"Sven","img":null,"order":578},{"name":"Alexis Conran","character":"Dr. Petridis","img":null,"order":579},{"name":"Miles Jupp","character":"Basil","img":null,"order":584}],"tv:225383":[{"name":"Luke Kirby","character":"Jack McMillan","img":null,"order":0},{"name":"Charlotte Gainsbourg","character":"Genevi\u00e8ve Lavigne","img":null,"order":1},{"name":"Lou de La\u00e2ge","character":"Cheyenne Toussaint","img":null,"order":2},{"name":"Gideon Glick","character":"Tobias Bell","img":null,"order":3},{"name":"David Alvarez","character":"Gael Rodriguez","img":null,"order":4},{"name":"Ivan du Pontavice","character":"Gabin","img":null,"order":5},{"name":"Ta\u00efs Vinolo","character":"Mishi Duplessis","img":null,"order":6},{"name":"David Haig","character":"Nicholas","img":null,"order":7},{"name":"LaMay Zhang","character":"Susu","img":null,"order":8},{"name":"Simon Callow","character":"Crispin Shamblee","img":null,"order":9},{"name":"Yanic Truesdale","character":"Rapha\u00ebl Marchand","img":null,"order":10},{"name":"Etai Benson","character":"Curt","img":null,"order":500},{"name":"Steve Belanger","character":"Al","img":null,"order":501}],"tv:4586":[{"name":"Lauren Graham","character":"Lorelai Gilmore","img":null,"order":0},{"name":"Alexis Bledel","character":"Rory Gilmore","img":null,"order":1},{"name":"Scott Patterson","character":"Luke Danes","img":null,"order":2},{"name":"Melissa McCarthy","character":"Sookie St. James","img":null,"order":3},{"name":"Keiko Agena","character":"Lane Kim","img":null,"order":4},{"name":"Yanic Truesdale","character":"Michel Gerard","img":null,"order":5},{"name":"Liza Weil","character":"Paris Geller","img":null,"order":6},{"name":"Jared Padalecki","character":"Dean Forester","img":null,"order":8},{"name":"Matt Czuchry","character":"Logan Huntzberger","img":null,"order":9},{"name":"Kelly Bishop","character":"Emily Gilmore","img":null,"order":10},{"name":"Edward Herrmann","character":"Richard Gilmore","img":null,"order":12},{"name":"Sean Gunn","character":"Kirk Gleason","img":null,"order":60},{"name":"Liz Torres","character":"Miss Patty","img":null,"order":692},{"name":"Michael Winters","character":"Taylor Doose","img":null,"order":697},{"name":"Jackson Douglas","character":"Jackson Belleville","img":null,"order":746}],"tv:117581":[{"name":"Antonia Gentry","character":"Virginia 'Ginny' Miller","img":null,"order":0},{"name":"Brianne Howey","character":"Georgia Miller","img":null,"order":1},{"name":"Diesel La Torraca","character":"Austin Miller","img":null,"order":2},{"name":"Jennifer Robertson","character":"Ellen Baker","img":null,"order":3},{"name":"Felix Mallard","character":"Marcus Baker","img":null,"order":4},{"name":"Sara Waisglass","character":"Maxine Baker","img":null,"order":5},{"name":"Scott Porter","character":"Mayor Paul Randolph","img":null,"order":6},{"name":"Raymond Ablack","character":"Joe","img":null,"order":7},{"name":"Mason Temple","character":"Hunter Chen","img":null,"order":8},{"name":"Katie Douglas","character":"Abigail 'Abby' Littman","img":null,"order":9},{"name":"Chelsea Clark","character":"Norah","img":null,"order":10},{"name":"Nikki Roumel","character":"Young Georgia","img":null,"order":205},{"name":"Sabrina Grdevich","character":"Cynthia Fuller","img":null,"order":502},{"name":"Colton Gobbo","character":"Jordan","img":null,"order":503},{"name":"Tyssen Smith","character":"Brodie","img":null,"order":504}],"tv:32504":[{"name":"Aly Michalka","character":"Marti Perkins","img":null,"order":0},{"name":"Ashley French","character":"Savannah Monroe","img":null,"order":1},{"name":"Heather Hemmens","character":"Alice Verdura","img":null,"order":2},{"name":"Robbie Jones","character":"Lewis Flynn","img":null,"order":3},{"name":"Matt Barr","character":"Dan Patch","img":null,"order":4},{"name":"Gail O'Grady","character":"Wanda Perkins","img":null,"order":5},{"name":"Sharon Leal","character":"Vanessa Lodge","img":null,"order":6},{"name":"Aaron Douglas","character":"Bill Marsh","img":null,"order":500},{"name":"Jeff Hephner","character":"Red Raymond","img":null,"order":501},{"name":"D. B. Woodside","character":"Derrick Altman","img":null,"order":502},{"name":"Elena Esovolova","character":"Patty Wedgerman","img":null,"order":503},{"name":"Emma Lahana","character":"Charlotte Monroe","img":null,"order":504},{"name":"Teryl Rothery","character":"Layne Monroe","img":null,"order":505},{"name":"Lauren Watson","character":"Judge (uncredited)","img":null,"order":506},{"name":"John Pyper-Ferguson","character":"Rex Perkins","img":null,"order":507}],"tv:226637":[{"name":"Kaitlin Olson","character":"Morgan Gillory","img":null,"order":0},{"name":"Daniel Sunjata","character":"Adam Karadec","img":null,"order":1},{"name":"Judy Reyes","character":"Selena Soto","img":null,"order":2},{"name":"Javicia Leslie","character":"Daphne Forrester","img":null,"order":3},{"name":"Deniz Akdeniz","character":"Lev 'Oz' Ozdil","img":null,"order":4},{"name":"Amirah J","character":"Ava Gillory","img":null,"order":5},{"name":"Matthew Lamb","character":"Elliot Radovic","img":null,"order":6},{"name":"Taran Killam","character":"Ludo Radovic","img":null,"order":500},{"name":"Garret Dillahunt","character":"Lieutenant Melon","img":null,"order":520},{"name":"TJ Lee","character":"Officer Lin","img":null,"order":522},{"name":"J. D. Pardo","character":"Tom","img":null,"order":570},{"name":"Mekhi Phifer","character":"Arthur Ellis","img":null,"order":686},{"name":"Steve Howey","character":"Nick Wagner","img":null,"order":719},{"name":"John Pyper-Ferguson","character":"Eric Hayworth","img":null,"order":754},{"name":"Susan Kelechi Watson","character":"Lucia","img":null,"order":774}],"tv:233079":[{"name":"Roisin Gallagher","character":"Saoirse","img":null,"order":0},{"name":"Sin\u00e9ad Keenan","character":"Robyn","img":null,"order":1},{"name":"Caoilfhionn Dunne","character":"Dara","img":null,"order":2},{"name":"Darragh Hand","character":"Liam","img":null,"order":3},{"name":"Natasha O'Keeffe","character":"Greta","img":null,"order":500},{"name":"Bronagh Gallagher","character":"Booker","img":null,"order":501},{"name":"Emmett J. Scanlan","character":"Owen","img":null,"order":502},{"name":"Emma Canning","character":"Young Greta","img":null,"order":503},{"name":"Michelle Fairley","character":"Margo","img":null,"order":504},{"name":"Jenn Murray","character":"Jamie","img":null,"order":510},{"name":"Eleanor Methven","character":"Mrs. Friel","img":null,"order":511},{"name":"Ardal O'Hanlon","character":"S\u00e9amus","img":null,"order":512},{"name":"Tom Basden","character":"Seb","img":null,"order":513},{"name":"Matilda Freeman","character":"Maria","img":null,"order":515},{"name":"Josh Finan","character":"Andrew Sampson","img":null,"order":520}],"tv:244447":[{"name":"Brittany Snow","character":"Sophie O\u2019Neill","img":null,"order":0},{"name":"Malin Akerman","character":"Margo Banks","img":null,"order":1},{"name":"Dermot Mulroney","character":"Jed Banks","img":null,"order":2},{"name":"Katie Lowes","character":"Jill Thompson","img":null,"order":3},{"name":"Jaime Ray Newman","character":"Callie","img":null,"order":4},{"name":"George Ferrier","character":"Brad Thompson","img":null,"order":4},{"name":"Chrissy Metz","character":"Starr Jackson","img":null,"order":4},{"name":"Evan Jonigkeit","character":"Graham O\u2019Neill","img":null,"order":5},{"name":"Alex Fitzalan","character":"Lincoln Trout","img":null,"order":6},{"name":"Hunter Emery","character":"Deputy Walter Flynn","img":null,"order":8},{"name":"Steve D'Avria","character":"Sheriff's Deputy","img":null,"order":9},{"name":"Karen Rodriguez","character":"Deputy Wanda Salazar","img":null,"order":10},{"name":"Madison Wolfe","character":"Abby Jackson","img":null,"order":503},{"name":"Alexandria DeBerry","character":"Taylor Tucker","img":null,"order":504},{"name":"Joyce Glenn","character":"Monae Roberts","img":null,"order":505}],"tv:125816":[{"name":"Sigourney Weaver","character":"June Hart","img":null,"order":0},{"name":"Alycia Debnam-Carey","character":"Alice Hart","img":null,"order":1},{"name":"Alyla Browne","character":"Young Alice Hart","img":null,"order":2},{"name":"Leah Purcell","character":"Twig","img":null,"order":3},{"name":"Frankie Adams","character":"Candy","img":null,"order":4},{"name":"Tilda Cobham-Hervey","character":"Agnes Hart","img":null,"order":5},{"name":"Asher Keddie","character":"Sally Morgan","img":null,"order":6},{"name":"Alexander England","character":"John Morgan","img":null,"order":7},{"name":"Charlie Vickers","character":"Clem Hart","img":null,"order":8},{"name":"Sebasti\u00e1n Zurita","character":"Dylan","img":null,"order":9},{"name":"Maggie Dence","character":"Boo","img":null,"order":502},{"name":"Dalara Williams","character":"Myf","img":null,"order":504},{"name":"Amy Kersey","character":"Rosie","img":null,"order":506},{"name":"Vivienne Awosoga","character":"Lulu","img":null,"order":514},{"name":"Shareena Clanton","character":"Ruby","img":null,"order":515}],"tv:99353":[{"name":"Nick Lachey","character":"Self - Host","img":null,"order":0},{"name":"Vanessa Lachey","character":"Self - Host","img":null,"order":1},{"name":"Cole Barnett","character":"Self - Contestant","img":null,"order":511},{"name":"Paul Peden","character":"Self - Contestant","img":null,"order":545},{"name":"Colleen Reed","character":"Self - Contestant","img":null,"order":581},{"name":"Matt Bolton","character":"Self - Contestant","img":null,"order":583},{"name":"Micah Lussier","character":"Self - Video Message","img":null,"order":594},{"name":"Alexa Alfia","character":"Self - Contestant","img":null,"order":602},{"name":"Brennon Lemieux","character":"Self - Video Message","img":null,"order":605},{"name":"Tiffany Pennywell","character":"Self - Contestant","img":null,"order":606},{"name":"Brett Brown","character":"Self - Video Message","img":null,"order":607},{"name":"Chelsea Griffin Appiah","character":"Self - Contestant","img":null,"order":607},{"name":"Zack Goytowski","character":"Self - Video Message","img":null,"order":608},{"name":"Kwame Appiah","character":"Self - Video Message","img":null,"order":610},{"name":"Bliss Poureetezadi","character":"Self - Video Message","img":null,"order":616}],"tv:285404":[{"name":"Tatiana Maslany","character":"Paula Sanders","img":null,"order":0},{"name":"Jake Johnson","character":"Karl Hendricks","img":null,"order":1},{"name":"Dolly de Leon","character":"Detective Sofia Gonzalez","img":null,"order":2},{"name":"Jessy Hodges","character":"Mallory","img":null,"order":3},{"name":"Jon Michael Hill","character":"Detective Baxter","img":null,"order":4},{"name":"Charlie Hall","character":"Rudy","img":null,"order":5},{"name":"Kiarra Hamagami Goldberg","character":"Geri","img":null,"order":6},{"name":"Nola Wallace","character":"Hazel","img":null,"order":7},{"name":"Brandon Flynn","character":"Trevor","img":null,"order":500},{"name":"Nina Bloomgarden","character":"Ash","img":null,"order":502},{"name":"Daniel Dale","character":"Sky","img":null,"order":504},{"name":"Remy Auberjonois","character":"Doug","img":null,"order":505},{"name":"Grace Porter","character":"Gwynn","img":null,"order":506},{"name":"Raymond Lee","character":"Steve","img":null,"order":507},{"name":"Murray Bartlett","character":"Dennis","img":null,"order":509}],"tv:118303":[{"name":"Ophelia Lovibond","character":"Joyce","img":null,"order":0},{"name":"Jake Johnson","character":"Doug","img":null,"order":1},{"name":"Michael Angarano","character":"Glenn","img":null,"order":2},{"name":"Jessica Lowe","character":"Bambi","img":null,"order":3},{"name":"Oscar Montoya","character":"Richie","img":null,"order":4},{"name":"Lennon Parham","character":"Shelly","img":null,"order":5},{"name":"Idara Victor","character":"Tina","img":null,"order":6},{"name":"Sofia Gonzalez","character":"Rita","img":null,"order":554},{"name":"David Lee Hess","character":"Phil","img":null,"order":557},{"name":"Jaeden Bettencourt","character":"Tommy","img":null,"order":572},{"name":"Rich Sommer","character":"Lenny","img":null,"order":576},{"name":"Elizabeth Perkins","character":"Constance","img":null,"order":579},{"name":"Lincoln A. Castellanos","character":"Daniel","img":null,"order":597},{"name":"Liora Belle","character":"Millie","img":null,"order":744},{"name":"Vera Myers","character":"Bottom Dollar Playmate (uncredited)","img":null,"order":766}],"tv:33155":[{"name":"Don Hany","character":"","img":null,"order":0},{"name":"Asher Keddie","character":"Nina Proudman","img":null,"order":1},{"name":"Deborah Mailman","character":"","img":null,"order":2},{"name":"Kat Stewart","character":"Billie Proudman","img":null,"order":3},{"name":"Richard Davies","character":"Jimmy Proudman","img":null,"order":4},{"name":"Linda Cropper","character":"Geraldine Proudman","img":null,"order":5},{"name":"Jane Harber","character":"Zara Perkich","img":null,"order":6},{"name":"Alicia Gardiner","character":"Kim Akerholt","img":null,"order":7},{"name":"Eddie Perfect","character":"Mick Holland","img":null,"order":8},{"name":"John Waters","character":"","img":null,"order":9},{"name":"Matthew Le Nevez","character":"","img":null,"order":10},{"name":"Lachy Hulme","character":"","img":null,"order":11},{"name":"Clare Bowditch","character":"Rosanna Harding","img":null,"order":12},{"name":"Kate Atkinson","character":"Renee","img":null,"order":508},{"name":"Shannon Berry","character":"Brody Jordan","img":null,"order":511}],"tv:107113":[{"name":"Steve Martin","character":"Charles-Haden Savage","img":null,"order":0},{"name":"Martin Short","character":"Oliver Putnam","img":null,"order":1},{"name":"Selena Gomez","character":"Mabel Mora","img":null,"order":2},{"name":"Amy Ryan","character":"Jan Bellows","img":null,"order":3},{"name":"Michael Cyril Creighton","character":"Howard Morris","img":null,"order":5},{"name":"Jackie Hoffman","character":"Uma Heller","img":null,"order":542},{"name":"Da'Vine Joy Randolph","character":"Donna Williams","img":null,"order":567},{"name":"Vanessa Aspillaga","character":"Ursula","img":null,"order":576},{"name":"Teddy Coluca","character":"Lester","img":null,"order":577},{"name":"Russell G. Jones","character":"Grover Stanley","img":null,"order":583},{"name":"Jayne Houdyshell","character":"Bunny Folger","img":null,"order":586},{"name":"Ryan Broussard","character":"Will Putnam","img":null,"order":588},{"name":"Meryl Streep","character":"Loretta Durkin","img":null,"order":598},{"name":"Paul Rudd","character":"Ben Glenroy","img":null,"order":644},{"name":"Richard Kind","character":"Vince Fish","img":null,"order":659}],"tv:207484":[{"name":"Harriet Slater","character":"Ellen MacKenzie","img":null,"order":0},{"name":"Jamie Roy","character":"Brian Fraser","img":null,"order":1},{"name":"Hermione Corfield","character":"Julia Moriston Beauchamp","img":null,"order":2},{"name":"Jeremy Irvine","character":"Henry Beauchamp","img":null,"order":3},{"name":"Tony Curran","character":"Lord Lovat (Simon Fraser)","img":null,"order":4},{"name":"S\u00e9amus McLean Ross","character":"Colum MacKenzie","img":null,"order":5},{"name":"Sam Retford","character":"Dougal MacKenzie","img":null,"order":6},{"name":"Rory Alexander","character":"Murtagh Fitzgibbons Fraser","img":null,"order":7},{"name":"Conor MacNeill","character":"Ned Gowan","img":null,"order":8},{"name":"Sara Vickers","character":"Davina Porter","img":null,"order":9},{"name":"Sadhbh Malin","character":"Jocasta MacKenzie Cameron","img":null,"order":502},{"name":"Jhon Lumsden","character":"Malcolm Grant","img":null,"order":503},{"name":"Sally Messham","character":"Mrs. Fitzgibbons","img":null,"order":506},{"name":"Terence Rae","character":"Arch Bug","img":null,"order":507},{"name":"Chick Allan","character":"Balloch","img":null,"order":514}],"tv:208921":[{"name":"David Tennant","character":"Tony Baddingham","img":null,"order":0},{"name":"Aidan Turner","character":"Declan O'Hara","img":null,"order":1},{"name":"Danny Dyer","character":"Freddie Jones","img":null,"order":2},{"name":"Alex Hassell","character":"Rupert Campbell-Black","img":null,"order":3},{"name":"Nafessa Williams","character":"Cameron Cook","img":null,"order":4},{"name":"Bella Maclean","character":"Taggie O'Hara","img":null,"order":5},{"name":"Katherine Parkinson","character":"Lizzie Vereker","img":null,"order":6},{"name":"Victoria Smurfit","character":"Maud O'Hara","img":null,"order":7},{"name":"Claire Rushbrook","character":"Lady Monica Baddingham","img":null,"order":8},{"name":"Oliver Chris","character":"James Vereker","img":null,"order":9},{"name":"Lisa McGrillis","character":"Valerie Jones","img":null,"order":10},{"name":"Emily Atack","character":"Sarah Stratton","img":null,"order":11},{"name":"Rufus Jones","character":"Paul Stratton","img":null,"order":12},{"name":"Luke Pasqualino","character":"Basil 'Bas' Baddingham","img":null,"order":13},{"name":"Catriona Chandler","character":"Caitlin O'Hara","img":null,"order":14}],"tv:254953":[{"name":"Steve Carell","character":"Greg Russo","img":null,"order":0},{"name":"Charly Clive","character":"Katie Russo","img":null,"order":1},{"name":"Danielle Deadwyler","character":"Dylan Shepard","img":null,"order":2},{"name":"Phil Dunster","character":"Archie Bates","img":null,"order":3},{"name":"John C. McGinley","character":"Walter Mann","img":null,"order":4},{"name":"Lauren Tsai","character":"Sunny Salewski","img":null,"order":5},{"name":"Annie Mumolo","character":"Cristle","img":null,"order":500},{"name":"Rory Scovel","character":"Officer Donnie Mullins","img":null,"order":501},{"name":"Cece Abbey","character":"Maya","img":null,"order":503},{"name":"Xavier Beloved","character":"George Heitman","img":null,"order":504},{"name":"Noah Grismer","character":"J.D.","img":null,"order":507},{"name":"Madison Hu","character":"Eva","img":null,"order":508},{"name":"Evan Jachelski","character":"Spooner","img":null,"order":509},{"name":"Maximo Salas","character":"Tommy Newton","img":null,"order":515},{"name":"Robby Hoffman","character":"Mo","img":null,"order":518}],"tv:105":[{"name":"Sarah Jessica Parker","character":"Carrie Bradshaw","img":null,"order":0},{"name":"Kim Cattrall","character":"Samantha Jones","img":null,"order":1},{"name":"Cynthia Nixon","character":"Miranda Hobbes","img":null,"order":2},{"name":"Kristin Davis","character":"Charlotte York","img":null,"order":3},{"name":"Lynn Cohen","character":"Magda","img":null,"order":826},{"name":"Willie Garson","character":"Stanford Blatch","img":null,"order":962},{"name":"Mario Cantone","character":"Anthony Marantino","img":null,"order":964},{"name":"Chris Noth","character":"Mr. Big","img":null,"order":982},{"name":"Kyle MacLachlan","character":"Trey MacDougal","img":null,"order":1001},{"name":"David Eigenberg","character":"Steve Brady","img":null,"order":1022},{"name":"Frances Sternhagen","character":"Bunny MacDougal","img":null,"order":1023},{"name":"John Corbett","character":"Aidan Shaw","img":null,"order":1026},{"name":"James Remar","character":"Richard Wright","img":null,"order":1040},{"name":"Jason Lewis","character":"Jerry 'Smith' Jerrod","img":null,"order":1062},{"name":"Evan Handler","character":"Harry Goldenblatt","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":1096}],"tv:133908":[{"name":"Natasha Calis","character":"Hayley Roberts","img":null,"order":0},{"name":"Morgan Holmstrom","character":"Crystal Highway","img":null,"order":1},{"name":"Aaron Ashmore","character":"William \"Wheezer\" Heaseman","img":null,"order":2},{"name":"Mercedes Morris","character":"Lexi Martine","img":null,"order":3},{"name":"Praneet Akilla","character":"Jay \"Chopper\" Chopra","img":null,"order":4},{"name":"Aason Nadjiwan","character":"Austen Bodie","img":null,"order":5},{"name":"Alexander Eling","character":"Wyatt Ellis","img":null,"order":6},{"name":"Thomas Elms","character":"Milosz Nowak","img":null,"order":8},{"name":"Kheon Clarke","character":"Tristan Green","img":null,"order":10},{"name":"Rebecca Kwan","character":"Emma Lin","img":null,"order":12},{"name":"Braeden Clarke","character":"Jeremy Wood","img":null,"order":14},{"name":"Emilia McCarthy","character":"Madison Van Camp","img":null,"order":15},{"name":"Patrick Kwok-Choon","character":"Trevor Sung","img":null,"order":16},{"name":"Sydney Kuhne","character":"Stef","img":null,"order":17},{"name":"Anthony Grant","character":"TJ","img":null,"order":18}],"tv:40231":[{"name":"EliZe","character":"Self - Presenter","img":null,"order":0},{"name":"Dan Karaty","character":"Self - Jury","img":null,"order":1},{"name":"Euvgenia Parakhina","character":"Self - Jury","img":null,"order":2},{"name":"Jaakko Toivonen","character":"Self - Jury","img":null,"order":3},{"name":"Nicky Vernieuwe","character":"Self - Jury","img":null,"order":4},{"name":"Marco Gerris","character":"Self - Jury","img":null,"order":5},{"name":"Min Hee Bervoets","character":"Self - Jury","img":null,"order":6},{"name":"Roberto da Costa","character":"Guest Jury","img":null,"order":7},{"name":"Brahim Attaeb","character":"Guest Jury","img":null,"order":8},{"name":"Eszteca Noya","character":"Guest Jury","img":null,"order":9},{"name":"Dennis Weening","character":"Self - Presenter","img":null,"order":10},{"name":"Ish A\u00eft Hamou","character":"Self - Jury","img":null,"order":11},{"name":"Eline De Munck","character":"Self - Presenter","img":null,"order":12},{"name":"Jan Kooijman","character":"Self - Jury","img":null,"order":13},{"name":"Wendy van Dijk","character":"Self - Jury","img":null,"order":257}],"tv:111803":[{"name":"Leslie Bibb","character":"Kate Bohr","img":null,"order":0},{"name":"Jennifer Coolidge","character":"Tanya McQuoid","img":null,"order":1},{"name":"Carrie Coon","character":"Laurie Duffy","img":null,"order":1},{"name":"Walton Goggins","character":"Rick Hatchett","img":null,"order":2},{"name":"Sarah Catherine Hook","character":"Piper Ratliff","img":null,"order":3},{"name":"Jason Isaacs","character":"Timothy Ratliff","img":null,"order":4},{"name":"Jon Gries","character":"Greg Hunt","img":null,"order":5},{"name":"Parker Posey","character":"Victoria Ratliff","img":null,"order":5},{"name":"Patrick Schwarzenegger","character":"Saxon Ratliff","img":null,"order":6},{"name":"Tayme Thapthimthong","character":"Gaitok","img":null,"order":7},{"name":"Aimee Lou Wood","character":"Chelsea","img":null,"order":8},{"name":"LISA","character":"Mook","img":null,"order":9},{"name":"Michelle Monaghan","character":"Jaclyn Lemon","img":null,"order":10},{"name":"Natasha Rothwell","character":"Belinda Lindsey","img":null,"order":11},{"name":"Sam Nivola","character":"Lochlan Ratliff","img":null,"order":12}],"tv:117488":[{"name":"Melanie Lynskey","character":"Shauna Sadecki","img":null,"order":0},{"name":"Tawny Cypress","character":"Taissa Turner","img":null,"order":1},{"name":"Sophie N\u00e9lisse","character":"Teen Shauna Shipman","img":null,"order":3},{"name":"Jasmin Savoy Brown","character":"Teen Taissa Turner","img":null,"order":4},{"name":"Sophie Thatcher","character":"Teen Natalie Scatorccio","img":null,"order":5},{"name":"Sammi Hanratty","character":"Teen Misty Quigley","img":null,"order":6},{"name":"Steven Krueger","character":"Ben Scott","img":null,"order":7},{"name":"Warren Kole","character":"Jeff Sadecki","img":null,"order":8},{"name":"Courtney Eaton","character":"Teen Lottie Matthews","img":null,"order":9},{"name":"Lauren Ambrose","character":"Van Palmer","img":null,"order":10},{"name":"Christina Ricci","character":"Misty Quigley","img":null,"order":11},{"name":"Kevin Alves","character":"Teen Travis","img":null,"order":14},{"name":"Liv Hewson","character":"Teen Van Palmer","img":null,"order":15},{"name":"Sarah Desjardins","character":"Callie Sadecki","img":null,"order":16},{"name":"Alexa Barajas","character":"Teen Mari Ibarra","img":null,"order":18}],"movie:21469":[{"name":"Mike Vogel","character":"Julian","img":null,"order":0},{"name":"Brittany Murphy","character":"June","img":null,"order":1},{"name":"Danny Pino","character":"Terry","img":null,"order":2},{"name":"Brad Greenquist","character":"The Porter","img":null,"order":3},{"name":"Arie Verveen","character":"Lucas","img":null,"order":4},{"name":"Natalie Smyka","character":"Anna","img":null,"order":5},{"name":"Guillermo D\u00edaz","character":"The Cook","img":null,"order":6},{"name":"Dov Davidoff","character":"The Bellhop","img":null,"order":7},{"name":"William Stanford Davis","character":"Custodian","img":null,"order":8},{"name":"Erika Ringor","character":"Detective","img":null,"order":9},{"name":"Mark Engelhardt","character":"Officer","img":null,"order":10},{"name":"Chase Kim","character":"Police Officer","img":null,"order":11},{"name":"Erika Seifred","character":"Dominatrix","img":null,"order":12},{"name":"Cameron Cash","character":"Georgie (uncredited)","img":null,"order":13}],"movie:235271":[{"name":"Sharon Leal","character":"Zoe Reynard","img":null,"order":0},{"name":"Boris Kodjoe","character":"Jason Reynard","img":null,"order":1},{"name":"John Newberg","character":"Balthazar Crayne","img":null,"order":2},{"name":"Brandon Gonzales","character":"Tony","img":null,"order":3},{"name":"Cameron Mills","character":"10-year-old Zoe","img":null,"order":4},{"name":"Emayatzy Corinealdi","character":"Brina","img":null,"order":5},{"name":"Garrett Hines","character":"Benny","img":null,"order":6},{"name":"Hunter Burke","character":"Shane","img":null,"order":7},{"name":"Kat Graham","character":"Diamond","img":null,"order":8},{"name":"Maria Howell","character":"Nina","img":null,"order":9},{"name":"Omer Mughal","character":"Handsome Man","img":null,"order":10},{"name":"Tasha Smith","character":"Marcella","img":null,"order":11},{"name":"Tyson Beckford","character":"Corey","img":null,"order":12},{"name":"William Levy","character":"Quentin Matthews","img":null,"order":13},{"name":"Mikayla Donlon","character":"Girl on Playground","img":null,"order":14}],"movie:376004":[{"name":"Sean Faris","character":"Samuel Deuprey","img":null,"order":0},{"name":"Danielle Savre","character":"Ashley","img":null,"order":1},{"name":"Mehcad Brooks","character":"Damien","img":null,"order":2},{"name":"Stephanie Charles","character":"Lola","img":null,"order":3},{"name":"Steffinnie Phrommany","character":"Jasmine","img":null,"order":4},{"name":"Rebecca Reaney","character":"Rebecca Scott","img":null,"order":5},{"name":"Phillip Brock","character":"Jimmy","img":null,"order":6},{"name":"Robert Eric Wise","character":"Tyrone","img":null,"order":7}],"movie:18206":[{"name":"Colin Hanks","character":"Doug","img":null,"order":0},{"name":"Ana Claudia Talanc\u00f3n","character":"Amy","img":null,"order":1},{"name":"Jordana Spiro","character":"Jen","img":null,"order":2},{"name":"Jonathon Trent","character":"Matt","img":null,"order":3},{"name":"Alex Boling","character":"Barrista","img":null,"order":4},{"name":"Tony Armatrading","character":"Cop","img":null,"order":5}],"movie:13483":[{"name":"Hayden Christensen","character":"Clay Beresford","img":null,"order":0},{"name":"Jessica Alba","character":"Sam Lockwood","img":null,"order":1},{"name":"Terrence Howard","character":"Dr. Jack Harper","img":null,"order":2},{"name":"Lena Olin","character":"Lilith Beresford","img":null,"order":3},{"name":"Christopher McDonald","character":"Dr. Larry Lupin","img":null,"order":4},{"name":"Sam Robards","character":"Clayton Beresford Sr.","img":null,"order":5},{"name":"Arliss Howard","character":"Dr. Jonathan Neyer","img":null,"order":6},{"name":"Fisher Stevens","character":"Dr. Puttnam","img":null,"order":7},{"name":"Georgina Chapman","character":"Penny Carver","img":null,"order":8},{"name":"David Harbour","character":"Dracula","img":null,"order":9},{"name":"Steven Hinkle","character":"Young Clay","img":null,"order":10},{"name":"Denis O'Hare","character":"Financial News Analyst","img":null,"order":11},{"name":"Charlie Hewson","character":"Brian the Orderly","img":null,"order":12},{"name":"Court Young","character":"Officer Doherty","img":null,"order":13},{"name":"Poorna Jagannathan","character":"Dr. Neyer's Nurse","img":null,"order":14}],"movie:624788":[{"name":"Aubrey Plaza","character":"Allison","img":null,"order":0},{"name":"Christopher Abbott","character":"Gabe","img":null,"order":1},{"name":"Sarah Gadon","character":"Blair","img":null,"order":2},{"name":"Paola L\u00e1zaro","character":"Cahya","img":null,"order":3},{"name":"Grantham Coleman","character":"Baako","img":null,"order":4},{"name":"Lindsay Burdge","character":"Maude","img":null,"order":5},{"name":"Lou Gonzalez","character":"Chris","img":null,"order":6},{"name":"Shannon O'Neill","character":"Simone","img":null,"order":7},{"name":"Alexander Koch","character":"Mike","img":null,"order":8},{"name":"Jennifer Kim","character":"Nora","img":null,"order":9},{"name":"Kevin Barker","character":"John the Gaffer","img":null,"order":10},{"name":"Mary Borrello","character":"1st AC","img":null,"order":11},{"name":"Rob Turbovsky","character":"2nd AC","img":null,"order":12},{"name":"Haitao Zeng","character":"PA","img":null,"order":13}],"movie:13250":[{"name":"Pierce Brosnan","character":"Tom Ryan","img":null,"order":0},{"name":"Maria Bello","character":"Abby Randall","img":null,"order":1},{"name":"Gerard Butler","character":"Neil Randall","img":null,"order":2},{"name":"Emma Karwandy","character":"Sophie Randall","img":null,"order":3},{"name":"Claudette Mink","character":"Judy Ryan","img":null,"order":4},{"name":"Desiree Zurowski","character":"Helen Schriver","img":null,"order":5},{"name":"Nicholas Lea","character":"Jerry Crane","img":null,"order":6},{"name":"Peter Keleghan","character":"Karl Granger","img":null,"order":7},{"name":"Samantha Ferris","character":"Diane","img":null,"order":8},{"name":"Malcolm Stewart","character":"Dave Carver","img":null,"order":9},{"name":"Callum Keith Rennie","character":"Det. McGill","img":null,"order":10},{"name":"Dustin Milligan","character":"Matt Ryan","img":null,"order":11},{"name":"Julie Patzwald","character":"Female Teller","img":null,"order":12},{"name":"Jason McKinnon","character":"French Waiter","img":null,"order":13},{"name":"Kurt Evans","character":"Maitre D'","img":null,"order":14}],"movie:317744":[{"name":"Nick Jonas","character":"Doug Martin","img":null,"order":0},{"name":"Isabel Lucas","character":"Lena Harper","img":null,"order":1},{"name":"Dermot Mulroney","character":"Eliott Harper","img":null,"order":2},{"name":"Kandyse McClure","character":"Angie Alvarez","img":null,"order":3},{"name":"Graham Rogers","character":"Carson","img":null,"order":4},{"name":"Marc Macaulay","character":"Gordon","img":null,"order":5},{"name":"David Sherrill","character":"Brian Martin","img":null,"order":6},{"name":"Kevin J. O'Connor","character":"Pier/Patron du bar","img":null,"order":7},{"name":"Paul Sorvino","character":"Sheriff Big Jack","img":null,"order":8},{"name":"Alex ter Avest","character":"Emma Shalloway","img":null,"order":9},{"name":"John Driskell Hopkins","character":"Gus","img":null,"order":10},{"name":"Ross Gallo","character":"Lena's Lawyer","img":null,"order":11},{"name":"Kiki Harris","character":"Emily/Doug's mother","img":null,"order":12}],"movie:1920":[{"name":"Barbara Stanwyck","character":"Kathy Ferguson","img":null,"order":0},{"name":"Sterling Hayden","character":"Lt. William Doyle","img":null,"order":1},{"name":"Raymond Burr","character":"Inspector Tony Pope","img":null,"order":2},{"name":"Fay Wray","character":"Alice Pope","img":null,"order":3},{"name":"Virginia Grey","character":"Sara Alidas","img":null,"order":4},{"name":"Royal Dano","character":"Capt. Charlie Alidas","img":null,"order":5},{"name":"Robert Griffin","character":"Police Sgt. James","img":null,"order":6},{"name":"Dennis Cross","character":"Police Sgt. Jules","img":null,"order":7},{"name":"Jay Adler","character":"Mr. Nalence","img":null,"order":8},{"name":"Stuart Whitman","character":"Laboratory Technician","img":null,"order":9},{"name":"Malcolm Atterbury","character":"Police Officer Spitz","img":null,"order":10},{"name":"Robert Quarry","character":"Sam, Reporter","img":null,"order":11},{"name":"Gail Bonney","character":"Mrs. London","img":null,"order":12},{"name":"Joe Conley","character":"Delivery Boy","img":null,"order":13},{"name":"Ralph Brooks","character":"Reporter in Newspaper Office (Uncredited)","img":null,"order":14}],"movie:619979":[{"name":"Ben Affleck","character":"Vic Van Allen","img":null,"order":0},{"name":"Ana de Armas","character":"Melinda Van Allen","img":null,"order":1},{"name":"Tracy Letts","character":"Don Wilson","img":null,"order":2},{"name":"Lil Rel Howery","character":"Grant","img":null,"order":3},{"name":"Dash Mihok","character":"Jonas Fernandez","img":null,"order":4},{"name":"Finn Wittrock","character":"Tony Cameron","img":null,"order":5},{"name":"Kristen Connolly","character":"Kelly Wilson","img":null,"order":6},{"name":"Jacob Elordi","character":"Charlie De Lisle","img":null,"order":7},{"name":"Rachel Blanchard","character":"Kristin Peterson","img":null,"order":8},{"name":"Michael Braun","character":"Jeff Peterson","img":null,"order":9},{"name":"Jade Fernandez","character":"Jen Fernandez","img":null,"order":10},{"name":"Grace Jenkins","character":"Trixie","img":null,"order":11},{"name":"Brendan Miller","character":"Joel Dash","img":null,"order":12},{"name":"Devyn A. Tyler","character":"Mary Washington","img":null,"order":13},{"name":"Jeff Pope","character":"Chief Nichols","img":null,"order":14}],"movie:8999":[{"name":"Clive Owen","character":"Charles Schine","img":null,"order":0},{"name":"Jennifer Aniston","character":"Lucinda Harris","img":null,"order":1},{"name":"Vincent Cassel","character":"LaRoche","img":null,"order":2},{"name":"Melissa George","character":"Deanna Schine","img":null,"order":3},{"name":"Giancarlo Esposito","character":"Detective Church","img":null,"order":4},{"name":"David Morrissey","character":"Sam Griffin","img":null,"order":5},{"name":"Georgina Chapman","character":"Candy","img":null,"order":6},{"name":"Denis O'Hare","character":"Jerry","img":null,"order":7},{"name":"Tom Conti","character":"Eliot Firth","img":null,"order":8},{"name":"Addison Timlin","character":"Amy Schine","img":null,"order":9},{"name":"RZA","character":"Winston Boyko","img":null,"order":10},{"name":"Xzibit","character":"Dexter","img":null,"order":11},{"name":"Sandra Bee","character":"Train Conductor","img":null,"order":12},{"name":"William Armstrong","character":"Accountant","img":null,"order":13},{"name":"Rachael Blake","character":"Susan Davis","img":null,"order":14}],"movie:1033148":[{"name":"Jennifer Lawrence","character":"Grace","img":null,"order":0},{"name":"Robert Pattinson","character":"Jackson","img":null,"order":1},{"name":"Sissy Spacek","character":"Pam","img":null,"order":2},{"name":"LaKeith Stanfield","character":"Karl","img":null,"order":3},{"name":"Nick Nolte","character":"Harry","img":null,"order":4},{"name":"Gabrielle Rose","character":"Jen","img":null,"order":5},{"name":"Clare Coulter","character":"Courtney","img":null,"order":6},{"name":"Sarah Lind","character":"Cheryl","img":null,"order":7},{"name":"Luke Camilleri","character":"Greg","img":null,"order":8},{"name":"Victor Zinck Jr.","character":"Tom","img":null,"order":9},{"name":"Debs Howard","character":"Marsha","img":null,"order":10},{"name":"Phillip Forest Lewitski","character":"Charlie","img":null,"order":11},{"name":"Georgina Lightning","character":"Kathleen the Orderly","img":null,"order":12},{"name":"Darren Moore","character":"Gerry","img":null,"order":13},{"name":"Lauren Viau","character":"Bessie","img":null,"order":14}],"movie:69668":[{"name":"Daniel Craig","character":"Will Atenton","img":null,"order":0},{"name":"Naomi Watts","character":"Ann Patterson","img":null,"order":1},{"name":"Rachel Weisz","character":"Libby Atenton","img":null,"order":2},{"name":"Marton Csokas","character":"Jack Patterson","img":null,"order":3},{"name":"Elias Koteas","character":"Boyce","img":null,"order":4},{"name":"Taylor Geare","character":"Trish","img":null,"order":5},{"name":"Claire Geare","character":"Dee Dee","img":null,"order":6},{"name":"Rachel Fox","character":"Chloe Patterson","img":null,"order":7},{"name":"Jane Alexander","character":"Dr. Greeley","img":null,"order":8},{"name":"Brian Murray","character":"Dr. Medlin","img":null,"order":9},{"name":"Bernadette Quigley","character":"Heather Keeler","img":null,"order":10},{"name":"Sarah Gadon","character":"Cindi","img":null,"order":11},{"name":"Gregory Smith","character":"Artie","img":null,"order":12},{"name":"David Huband","character":"Officer Nelson","img":null,"order":13},{"name":"Martin Roach","character":"Tommy","img":null,"order":14}],"movie:18190":[{"name":"James Spader","character":"Ray Reardon","img":null,"order":0},{"name":"M\u00e4dchen Amick","character":"Lena Mathers","img":null,"order":1},{"name":"Fredric Lehne","character":"Larry","img":null,"order":2},{"name":"Bess Armstrong","character":"Elaine","img":null,"order":3},{"name":"Larry Miller","character":"Norman","img":null,"order":4},{"name":"Kathleen York","character":"Martha","img":null,"order":5},{"name":"Kate Williamson","character":"Mrs. Sneeder","img":null,"order":6},{"name":"Tom Lillard","character":"Hank Sneeder","img":null,"order":7},{"name":"William Shockley","character":"Buddy","img":null,"order":8},{"name":"Carl Sundstrom","character":"Officer","img":null,"order":9},{"name":"Irwin Keyes","character":"Officer","img":null,"order":10},{"name":"Joel McKinnon Miller","character":"Minister","img":null,"order":11},{"name":"Joseph Scoren","character":"Martha's Lawyer (as Joseph Scorsiani)","img":null,"order":12},{"name":"Archie Lang","character":"Judge #1","img":null,"order":13},{"name":"Clyde Kusatsu","character":"Judge Kurita","img":null,"order":14}],"movie:1249213":[{"name":"Meghann Fahy","character":"Violet","img":null,"order":0},{"name":"Brandon Sklenar","character":"Henry","img":null,"order":1},{"name":"Reed Diamond","character":"Richard","img":null,"order":2},{"name":"Violett Beane","character":"Jen","img":null,"order":3},{"name":"Jeffery Self","character":"Matt","img":null,"order":4},{"name":"Jacob Robinson","character":"Toby","img":null,"order":5},{"name":"Gabrielle Ryan","character":"Cara","img":null,"order":6},{"name":"Ed Weeks","character":"Phil","img":null,"order":7},{"name":"Travis Nelson","character":"Connor","img":null,"order":8},{"name":"Sarah McCormack","character":"Hostess","img":null,"order":9},{"name":"Ben Pelletier","character":"Bearded Man / Masked Man","img":null,"order":10},{"name":"Saoirse Hayden","character":"Green Prom Dress Girl","img":null,"order":11},{"name":"Fiona Browne","character":"Diane","img":null,"order":12},{"name":"Stephanie Karam","character":"Young Woman (Zoom)","img":null,"order":13},{"name":"Michael Shea","character":"Blake","img":null,"order":14}],"movie:2252":[{"name":"Viggo Mortensen","character":"Nikolai Luzhin","img":null,"order":0},{"name":"Naomi Watts","character":"Anna Ivanovna Khitrova","img":null,"order":1},{"name":"Vincent Cassel","character":"Kirill","img":null,"order":2},{"name":"Armin Mueller-Stahl","character":"Semyon","img":null,"order":3},{"name":"Sin\u00e9ad Cusack","character":"Helen","img":null,"order":4},{"name":"Donald Sumpter","character":"Yuri","img":null,"order":5},{"name":"Jerzy Skolimowski","character":"Stepan","img":null,"order":6},{"name":"Josef Altin","character":"Ekrem","img":null,"order":7},{"name":"Aleksandar Mikic","character":"Soyka","img":null,"order":8},{"name":"Sarah-Jeanne Labrosse","character":"Tatiana","img":null,"order":9},{"name":"Lalita Ahmed","character":"Customer","img":null,"order":10},{"name":"Tatiana Maslany","character":"Tatiana (voice)","img":null,"order":11},{"name":"Tereza Srbova","character":"Kirilenko","img":null,"order":12},{"name":"David Papava","character":"Chechen","img":null,"order":13},{"name":"Tamer Hassan","character":"Chechen","img":null,"order":14}],"movie:753":[{"name":"John Marley","character":"Richard Forst","img":null,"order":0},{"name":"Gena Rowlands","character":"Jeannie Rapp","img":null,"order":1},{"name":"Lynn Carlin","character":"Maria Forst","img":null,"order":2},{"name":"Fred Draper","character":"Freddie Draper","img":null,"order":3},{"name":"Seymour Cassel","character":"Chet","img":null,"order":4},{"name":"Val Avery","character":"Jim McCarthy","img":null,"order":5},{"name":"Dorothy Gulliver","character":"Florence","img":null,"order":6},{"name":"Joanne Moore Jordan","character":"Louise Draper","img":null,"order":7},{"name":"Darlene Conley","character":"Billy Mae","img":null,"order":8},{"name":"Gene Darfler","character":"Joe Jackson","img":null,"order":9},{"name":"Elizabeth Deering","character":"Stella","img":null,"order":10},{"name":"George Dunn","character":"Comedian (as George Dunne)","img":null,"order":11},{"name":"Laurie Mock","character":"Barmaid","img":null,"order":12},{"name":"Christina Crawford","character":"Woman Scattering Coins on Bar","img":null,"order":13},{"name":"George Sims","character":"Bartender","img":null,"order":14}],"movie:549514":[{"name":"Hilary Swank","character":"Det. Valerie Quinlan","img":null,"order":0},{"name":"Michael Ealy","character":"Derrick Tyler","img":null,"order":1},{"name":"Mike Colter","character":"Rafe Grimes","img":null,"order":2},{"name":"Damaris Lewis","character":"Tracie Tyler","img":null,"order":3},{"name":"Tyrin Turner","character":"Tyrin Abenathy","img":null,"order":4},{"name":"Danny Pino","character":"Carter Heywood","img":null,"order":5},{"name":"Geoffrey Owens","character":"Bill Cranepool","img":null,"order":6},{"name":"David Hoflin","character":"Officer Lowe","img":null,"order":7},{"name":"Sam Daly","character":"Officer Stallman","img":null,"order":8},{"name":"Lance Stephenson","character":"Sports Star Cameo","img":null,"order":9},{"name":"Chic Daniel","character":"Investigator","img":null,"order":10},{"name":"Stephen O'Mahoney","character":"Uniformed Police Officer","img":null,"order":11},{"name":"Lexa Gluck","character":"Carter's Trophy Wife","img":null,"order":12},{"name":"Oakley Bull","character":"Haley Older / Haley Younger","img":null,"order":13},{"name":"Ian Stanley","character":"First Officer","img":null,"order":14}],"tv:241372":[{"name":"Dove Cameron","character":"Ciara Wyse","img":null,"order":0},{"name":"Avan Jogia","character":"Oliver Kennedy","img":null,"order":1},{"name":"Dorian Missick","character":"Karl Connelly","img":null,"order":2},{"name":"Karla Souza","character":"Lee Reardon","img":null,"order":3},{"name":"Kingston Rumi Southwick","character":"Young Oliver","img":null,"order":4},{"name":"Patch Darragh","character":"Dan Troxler","img":null,"order":5},{"name":"Kira Guloien","character":"Jane Miller","img":null,"order":6},{"name":"Megan Peta Hill","character":"Shyla","img":null,"order":7}],"tv:4493":[{"name":"Jennifer Saunders","character":"Edina Monsoon","img":null,"order":0},{"name":"Joanna Lumley","character":"Patsy Stone","img":null,"order":1},{"name":"Julia Sawalha","character":"Saffron 'Saffy' Monsoon","img":null,"order":2},{"name":"June Whitfield","character":"Mother","img":null,"order":3},{"name":"Jane Horrocks","character":"Bubble / Katy Grin","img":null,"order":118},{"name":"Naoko Mori","character":"Sarah","img":null,"order":505},{"name":"Christopher Malcolm","character":"Justin","img":null,"order":509},{"name":"Kathy Burke","character":"Magda","img":null,"order":510},{"name":"Harriet Thorpe","character":"Fleur","img":null,"order":512},{"name":"Christopher Ryan","character":"Marshall","img":null,"order":513},{"name":"Mo Gaffney","character":"Bo","img":null,"order":514},{"name":"Lulu","character":"Self","img":null,"order":514},{"name":"Helen Lederer","character":"Catriona","img":null,"order":518},{"name":"Tilly Blackwood","character":"Lady Candida De Denison-Bender","img":null,"order":624},{"name":"Llewella Gideon","character":"Nurse","img":null,"order":641}],"tv:106454":[{"name":"Tiffany Haddish","character":"Detective Danner","img":null,"order":0},{"name":"Sam Richardson","character":"Aniq","img":null,"order":1},{"name":"Zo\u00eb Chao","character":"Zoe","img":null,"order":2},{"name":"Ben Schwartz","character":"Yasper","img":null,"order":3},{"name":"Jamie Demetriou","character":"Walt","img":null,"order":7},{"name":"John Early","character":"Detective Culp","img":null,"order":8},{"name":"John Cho","character":"Ulysses","img":null,"order":10},{"name":"Paul Walter Hauser","character":"Travis","img":null,"order":11},{"name":"Ken Jeong","character":"Feng","img":null,"order":12},{"name":"Anna Konkle","character":"Hannah","img":null,"order":13},{"name":"Poppy Liu","character":"Grace","img":null,"order":14},{"name":"Elizabeth Perkins","character":"Isabel","img":null,"order":15},{"name":"Jack Whitehall","character":"Sebastian","img":null,"order":16},{"name":"Zach Woods","character":"Edgar","img":null,"order":17},{"name":"Vivian Wu","character":"Vivian","img":null,"order":18}],"tv:6127":[{"name":"Julia McKenzie","character":"Miss Marple","img":null,"order":0},{"name":"Geraldine McEwan","character":"Miss Marple","img":null,"order":1},{"name":"Emilia Fox","character":"Joanna Burton","img":null,"order":2},{"name":"Sylvia Syms","character":"Lavinia Pinkerton","img":null,"order":500},{"name":"Russell Tovey","character":"PC Terence Reed","img":null,"order":501},{"name":"Hugo Speer","character":"James Abbot","img":null,"order":502},{"name":"Shirley Henderson","character":"Honoria Waynflete","img":null,"order":503},{"name":"Margo Stilley","character":"Bridget Conway","img":null,"order":504},{"name":"Steve Pemberton","character":"Henry Wake","img":null,"order":505},{"name":"Benedict Cumberbatch","character":"Luke Fitzwilliam","img":null,"order":506},{"name":"Anna Chancellor","character":"Lydia Horton","img":null,"order":507},{"name":"Jemma Redgrave","character":"Jessie Humbleby","img":null,"order":508},{"name":"Lyndsey Marshal","character":"Amy Gibbs","img":null,"order":509},{"name":"David Haig","character":"Major Hugh Horton","img":null,"order":510},{"name":"Joanna Lumley","character":"Dolly Bantry","img":null,"order":679}],"tv:153723":[{"name":"Geffri Maya","character":"Simone Hicks","img":null,"order":0},{"name":"Peyton Alex Smith","character":"Damon Sims","img":null,"order":1},{"name":"Kelly Jenrette","character":"Amara Patterson","img":null,"order":2},{"name":"Cory Hardrict","character":"Marcus Turner","img":null,"order":3},{"name":"Sylvester Powell","character":"Jessie 'JR' Raymond","img":null,"order":4},{"name":"Camille Hyde","character":"Thea Mays","img":null,"order":5},{"name":"Mitchell Edwards","character":"Cam Watkins","img":null,"order":6},{"name":"Netta Walker","character":"Keisha McCalla","img":null,"order":7},{"name":"Rhoyle Ivy King","character":"Nathaniel Hardin","img":null,"order":8},{"name":"Leonard Roberts","character":"Zeke Allen","img":null,"order":505},{"name":"Derek Rivera","character":"Santiago Reyes","img":null,"order":510},{"name":"Joe Holt","character":"Jesse","img":null,"order":529},{"name":"Heather Lynn Harris","character":"Gabrielle","img":null,"order":554},{"name":"Martin Bobb-Semple","character":"Orlando 'Lando' Johnson","img":null,"order":555},{"name":"Taylor Polidore","character":"Tootie","img":null,"order":579}],"tv:116450":[{"name":"Sarah Jessica Parker","character":"Carrie Bradshaw","img":null,"order":0},{"name":"Cynthia Nixon","character":"Miranda Hobbes","img":null,"order":1},{"name":"Kristin Davis","character":"Charlotte York","img":null,"order":2},{"name":"Sara Ramirez","character":"Che Diaz","img":null,"order":3},{"name":"Cathy Ang","character":"Lily Goldenblatt","img":null,"order":4},{"name":"Mario Cantone","character":"Anthony Marentino","img":null,"order":5},{"name":"Sarita Choudhury","character":"Seema Patel","img":null,"order":6},{"name":"Evan Handler","character":"Harry Goldenblatt","img":"yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","order":7},{"name":"Christopher Jackson","character":"Herbert Wexley","img":null,"order":8},{"name":"Nicole Ari Parker","character":"Lisa Todd Wexley","img":null,"order":9},{"name":"Alexa Swinton","character":"Rose 'Rock' Goldenblatt","img":null,"order":10},{"name":"Karen Pittman","character":"Dr. Nya Wallace","img":null,"order":11},{"name":"Niall Cunningham","character":"Brady Hobbes","img":null,"order":12},{"name":"Alexander Bello","character":"Henry Wexley","img":null,"order":515},{"name":"Sebastiano Pigazzi","character":"Giuseppe","img":null,"order":620}],"tv:70785":[{"name":"Amybeth McNulty","character":"Anne Shirley","img":null,"order":0},{"name":"Geraldine James","character":"Marilla Cuthbert","img":null,"order":1},{"name":"R.H. Thomson","character":"Matthew Cuthbert","img":null,"order":2},{"name":"Dalila Bela","character":"Diana Barry","img":null,"order":3},{"name":"Lucas Jade Zumann","character":"Gilbert Blythe","img":null,"order":4},{"name":"Corrine Koslo","character":"Rachel Lynde","img":null,"order":5},{"name":"Jonathan Holmes","character":"Mr. William Barry","img":null,"order":539},{"name":"Helen Johns","character":"Mrs. Eliza Barry","img":null,"order":543},{"name":"Lia Pappas-Kemps","character":"Jane Andrews","img":null,"order":567},{"name":"Kyla Matthews","character":"Ruby Gillis","img":null,"order":569},{"name":"Miranda McKeon","character":"Josie Pye","img":null,"order":570},{"name":"Jacob Horsley","character":"Charlie Sloane","img":null,"order":572},{"name":"Jacob Ursomarzo","character":"Moody Spurgeon","img":null,"order":573},{"name":"Dalmar Abuzeid","character":"Sebastian 'Bash' Lacroix","img":null,"order":574},{"name":"Aymeric Jett Montaz","character":"Jerry Baynard","img":null,"order":579}],"tv:39859":[{"name":"Ashley Rickards","character":"Jenna Hamilton","img":null,"order":0},{"name":"Beau Mirchoff","character":"Matty McKibben","img":null,"order":1},{"name":"Nikki DeLoach","character":"Lacey Hamilton","img":null,"order":2},{"name":"Jillian Rose Reed","character":"Tamara Kaplan","img":null,"order":4},{"name":"Brett Davern","character":"Jake Rosati","img":null,"order":5},{"name":"Molly Tarlov","character":"Sadie Saxton","img":null,"order":6},{"name":"Desi Lydic","character":"Valerie Marks","img":null,"order":7},{"name":"Jessica Lu","character":"Ming Huang","img":null,"order":8},{"name":"Greer Grammer","character":"Lissa Miller","img":null,"order":9},{"name":"Meaghan Jette Martin","character":"Julie #2","img":null,"order":10},{"name":"Wesam Keesh","character":"Kyle Cohen","img":null,"order":522},{"name":"Barret Swatek","character":"Ally Saxton","img":null,"order":617},{"name":"Monty Geer","character":"Cole","img":null,"order":642},{"name":"Evan Crooks","character":"Theo","img":null,"order":643},{"name":"Niko Pepaj","character":"Sergio","img":null,"order":694}],"tv:100412":[{"name":"Sophie Grace","character":"Kristy Thomas","img":null,"order":0},{"name":"Momona Tamada","character":"Claudia Kishi","img":null,"order":1},{"name":"Shay Rudolph","character":"Stacey McGill","img":null,"order":2},{"name":"Malia Baker","character":"Mary Anne Spier","img":null,"order":3},{"name":"Alicia Silverstone","character":"Elizabeth Thomas-Brewer","img":null,"order":500},{"name":"Mark Feuerstein","character":"Watson Brewer","img":null,"order":501},{"name":"Takayo Fischer","character":"Mimi Yamamoto","img":null,"order":502},{"name":"Benjamin Goas","character":"David Michael Thomas","img":null,"order":505},{"name":"Sophia Reid-Gantzert","character":"Karen Brewer","img":null,"order":509},{"name":"Marc Evan Jackson","character":"Richard Spier","img":null,"order":515},{"name":"Xochitl Gomez","character":"Dawn Schafer","img":null,"order":536},{"name":"Jessica Elaina Eason","character":"Sharon Porter","img":null,"order":537},{"name":"Vivian Watson","character":"Mallory Pike","img":null,"order":562},{"name":"Anais Lee","character":"Jessi Ramsey","img":null,"order":577},{"name":"Kyndra Sanchez","character":"Dawn Schafer","img":null,"order":587}],"tv:62704":[{"name":"Dwayne Johnson","character":"Spencer Strassmore","img":null,"order":0},{"name":"Rob Corddry","character":"Joe Krutel","img":null,"order":1},{"name":"John David Washington","character":"Ricky Jerret","img":null,"order":2},{"name":"Omar Benson Miller","character":"Charles Greane","img":null,"order":3},{"name":"Troy Garity","character":"Jason Antolotti","img":null,"order":4},{"name":"London Brown","character":"Reggie","img":null,"order":5},{"name":"Donovan W. Carter","character":"Vernon Littlefield","img":null,"order":6},{"name":"Arielle Kebbel","character":"Tracy Legette","img":null,"order":7},{"name":"Jazmyn Simon","character":"Julie Greane","img":null,"order":9},{"name":"Brittany S. Hall","character":"Amber","img":null,"order":10},{"name":"Dul\u00e9 Hill","character":"Larry Siefert","img":null,"order":501},{"name":"Richard Schiff","character":"Brett Anderson","img":null,"order":503},{"name":"Jay Glazer","character":"Jay Glazer","img":null,"order":647},{"name":"Carl McDowell","character":"TTD","img":null,"order":673},{"name":"Robert Wisdom","character":"Dennis","img":null,"order":674}],"tv:3216":[{"name":"Robert Blake","character":"Det. Tony Baretta","img":null,"order":0},{"name":"Dana Elcar","character":"Inspector Shiller","img":null,"order":1},{"name":"Sharon Cintron","character":"","img":null,"order":2},{"name":"Burt Young","character":"Solomon","img":null,"order":368},{"name":"Michael D. Roberts","character":"","img":null,"order":500},{"name":"Val Avery","character":"","img":null,"order":501},{"name":"Joshua Shelley","character":"","img":null,"order":502},{"name":"Timothy Carey","character":"","img":null,"order":554},{"name":"Sharon Cintron","character":"","img":null,"order":565},{"name":"Art Metrano","character":"","img":null,"order":649},{"name":"Tom Ewell","character":"","img":null,"order":667},{"name":"Strother Martin","character":"","img":null,"order":678},{"name":"Ron Thompson","character":"","img":null,"order":689},{"name":"Elisha Cook Jr.","character":"","img":null,"order":700},{"name":"Angelo Rossitto","character":"Little Moe (uncredited)","img":null,"order":708}],"tv:197373":[{"name":"Kaley Cuoco","character":"Ava Bartlett","img":null,"order":0},{"name":"Chris Messina","character":"Nathan Bartlett","img":null,"order":1},{"name":"Tom Bateman","character":"Matt Pierce","img":null,"order":2},{"name":"Priscilla Quintana","character":"Ruby Gale","img":null,"order":3},{"name":"Liana Liberato","character":"Tory Thompson","img":null,"order":4},{"name":"Li Jun Li","character":"Michelle","img":null,"order":502},{"name":"Annabelle Dexter-Jones","character":"Serena","img":null,"order":503},{"name":"Aisha Alfa","character":"Carolyn","img":null,"order":504},{"name":"Alex Alomar Akpobome","character":"Ryan","img":null,"order":505},{"name":"June Diane Raphael","character":"Romy Lipinski","img":null,"order":506},{"name":"Brandon Keener","character":"Paul","img":null,"order":507},{"name":"Miles Mussenden","character":"Detective Quincy Burrell","img":null,"order":508},{"name":"Jessica St. Clair","character":"Rochelle Lipinski","img":null,"order":519},{"name":"Sebastian Quinn","character":"Carlos","img":null,"order":521},{"name":"Melissa Fumero","character":"Drew","img":null,"order":559}],"tv:250504":[{"name":"Claire Danes","character":"Agatha 'Aggie' Wiggs","img":null,"order":0},{"name":"Matthew Rhys","character":"Nile Jarvis","img":null,"order":1},{"name":"Brittany Snow","character":"Nina Jarvis","img":null,"order":2},{"name":"Natalie Morales","character":"Shelley Morris","img":null,"order":3},{"name":"Jonathan Banks","character":"Martin Jarvis","img":null,"order":500},{"name":"Tim Guinee","character":"Rick 'Wrecking Ball' Jarvis","img":null,"order":501},{"name":"David Lyons","character":"Brian Abbott","img":null,"order":502},{"name":"Bubba Weiler","character":"Teddy Fenig","img":null,"order":503},{"name":"Deirdre O'Connell","character":"Carol McGiddish","img":null,"order":504},{"name":"Leonard Gerome","character":"Cooper Morris-Wiggs","img":null,"order":508},{"name":"Hettienne Park","character":"Erika Breton","img":null,"order":514},{"name":"Aleyse Shannon","character":"Olivia Benitez","img":null,"order":515},{"name":"Julie Ann Emery","character":"Lila Jarvis","img":null,"order":519},{"name":"Amir Arison","character":"Frank Breton","img":null,"order":520},{"name":"Peter Grosz","character":"Elijah Garrity","img":null,"order":526}],"tv:80587":[{"name":"Chris Houghton","character":"Cricket Green (voice)","img":null,"order":0},{"name":"Marieve Herington","character":"Tilly Green (voice)","img":null,"order":1},{"name":"Bob Joles","character":"Bill Green (voice)","img":null,"order":2},{"name":"Artemis Pebdani","character":"Alice 'Gramma' Green (voice)","img":null,"order":3},{"name":"Zeno Robinson","character":"Remy (voice)","img":null,"order":4},{"name":"Wendi McLendon-Covey","character":"Nancy Green (voice)","img":null,"order":5},{"name":"Raven-Symon\u00e9","character":"Maria Media (voice)","img":null,"order":503},{"name":"Anna Akana","character":"Gloria (voice)","img":null,"order":512},{"name":"Danny Trejo","character":"Vasquez (voice)","img":null,"order":513},{"name":"Andy Daly","character":"Officer Keys (voice)","img":null,"order":514},{"name":"Paul Scheer","character":"Chip Whistler (voice)","img":null,"order":516},{"name":"Vladimir Caama\u00f1o","character":"Juan Pablo (voice)","img":null,"order":517},{"name":"Jenna Ortega","character":"Gabriella Espinosa (voice)","img":null,"order":521},{"name":"Paul F. Tompkins","character":"Tooth Fairy (voice)","img":null,"order":523},{"name":"Shane Houghton","character":"Driver Construction Worker (voice)","img":null,"order":541}],"tv:69630":[{"name":"Katie Stevens","character":"Jane Sloan","img":null,"order":0},{"name":"Aisha Dee","character":"Kat Edison","img":null,"order":1},{"name":"Meghann Fahy","character":"Sutton Brady","img":null,"order":2},{"name":"Melora Hardin","character":"Jacqueline Carlyle","img":null,"order":3},{"name":"Stephen Conrad Moore","character":"Oliver Grayson","img":null,"order":4},{"name":"Matt Ward","character":"Alex Crawford","img":null,"order":5},{"name":"Sam Page","character":"Richard 'Dick' Mott","img":null,"order":6},{"name":"Nikohl Boosheri","character":"Aden 'Adena' El Amin","img":null,"order":7},{"name":"Luca James Lee","character":"Ben Chau","img":null,"order":625},{"name":"Alexis Floyd","character":"Tia Clayton","img":null,"order":699},{"name":"Peter Vack","character":"Patrick Duchand","img":null,"order":711},{"name":"Adam Capriolo","character":"Andrew Morrison","img":null,"order":856},{"name":"Stephanie Costa","character":"Sage Aiello","img":null,"order":857},{"name":"Dan Jeannotte","character":"Ryan Decker","img":null,"order":859},{"name":"Gildart Jackson","character":"Ian Carlyle","img":null,"order":860}],"tv:92621":[{"name":"Joseph Gilgun","character":"Vincent \"Vinnie\" O'Neill","img":null,"order":0},{"name":"Michelle Keegan","character":"Erin Croft","img":null,"order":1},{"name":"Steve Evets","character":"Jim","img":null,"order":2},{"name":"Damien Molony","character":"Dylan Golding","img":null,"order":3},{"name":"Bronagh Gallagher","character":"Carol Dennings","img":null,"order":4},{"name":"Tom Hanson","character":"Leslie \"Cardi\" Titt","img":null,"order":5},{"name":"Aaron Heffernan","character":"Ashley \"Ash\" Dennings","img":null,"order":6},{"name":"Joanna Higson","character":"Mary \"Sugar\" Jones","img":null,"order":7},{"name":"Parth Thakerar","character":"Jehan \"JJ\" Jovani","img":null,"order":8},{"name":"Ryan Sampson","character":"Thomas \"Tommo\" Schaffner","img":null,"order":9},{"name":"Dominic West","character":"Dr. Chris Coxley","img":null,"order":10},{"name":"Ramon Tikaram","character":"Terence McCann","img":null,"order":11},{"name":"Tadhg Murphy","character":"Gary Cullen","img":null,"order":14},{"name":"Neil Ashton","character":"David \"Davey\" MacDonagh","img":null,"order":16},{"name":"Jude Riordan","character":"Tyler Croft","img":null,"order":501}],"movie:4982":[{"name":"Denzel Washington","character":"Frank Lucas","img":null,"order":0},{"name":"Russell Crowe","character":"Richie Roberts","img":null,"order":1},{"name":"Josh Brolin","character":"Detective Trupo","img":null,"order":2},{"name":"Chiwetel Ejiofor","character":"Huey Lucas","img":null,"order":3},{"name":"Cuba Gooding Jr.","character":"Nicky Barnes","img":null,"order":4},{"name":"Lymari Nadal","character":"Eva","img":null,"order":5},{"name":"Ted Levine","character":"Lou Toback","img":null,"order":6},{"name":"Armand Assante","character":"Dominic Cattano","img":null,"order":7},{"name":"John Ortiz","character":"Javier J. Rivera","img":null,"order":8},{"name":"John Hawkes","character":"Freddie Spearman","img":null,"order":9},{"name":"RZA","character":"Moses Jones","img":null,"order":10},{"name":"Yul Vazquez","character":"Alfonso Abruzzo","img":null,"order":11},{"name":"Ruby Dee","character":"Mama Lucas","img":null,"order":12},{"name":"Idris Elba","character":"Tango","img":null,"order":13},{"name":"Carla Gugino","character":"Laurie Roberts","img":null,"order":14}],"movie:703":[{"name":"Diane Keaton","character":"Annie Hall","img":null,"order":0},{"name":"Woody Allen","character":"Alvy Singer","img":null,"order":1},{"name":"Tony Roberts","character":"Rob","img":null,"order":2},{"name":"Carol Kane","character":"Allison","img":null,"order":3},{"name":"Paul Simon","character":"Tony Lacey","img":null,"order":4},{"name":"Shelley Duvall","character":"Pam","img":null,"order":5},{"name":"Janet Margolin","character":"Robin","img":null,"order":6},{"name":"Colleen Dewhurst","character":"Mrs. Hall","img":null,"order":7},{"name":"Christopher Walken","character":"Duane Hall","img":null,"order":8},{"name":"Donald Symington","character":"Dad Hall","img":null,"order":9},{"name":"Helen Ludlam","character":"Grammy Hall","img":null,"order":10},{"name":"Mordecai Lawner","character":"Alvy's Dad","img":null,"order":11},{"name":"Joan Neuman","character":"Alvy's Mom","img":null,"order":12},{"name":"Jonathan Munk","character":"Alvy - Age 9","img":null,"order":13},{"name":"Ruth Volner","character":"Alvy's Aunt","img":null,"order":14}],"movie:28":[{"name":"Martin Sheen","character":"Captain Benjamin Willard","img":null,"order":0},{"name":"Marlon Brando","character":"Colonel Walter Kurtz","img":null,"order":1},{"name":"Frederic Forrest","character":"Jay 'Chef' Hicks","img":null,"order":2},{"name":"Albert Hall","character":"Chief Phillips","img":null,"order":3},{"name":"Laurence Fishburne","character":"Tyrone 'Clean' Miller","img":null,"order":4},{"name":"Sam Bottoms","character":"Lance B. Johnson","img":null,"order":5},{"name":"Robert Duvall","character":"Lieutenant Colonel Bill Kilgore","img":null,"order":6},{"name":"G.D. Spradlin","character":"General Corman","img":null,"order":7},{"name":"Harrison Ford","character":"Colonel","img":null,"order":8},{"name":"Jerry Ziesmer","character":"Jerry, Civilian","img":null,"order":9},{"name":"Scott Glenn","character":"Lieutenant Richard M. Colby","img":null,"order":10},{"name":"Kerry Rossall","character":"Mike from San Diego","img":null,"order":11},{"name":"James Keane","character":"Kilgore's Gunner","img":null,"order":12},{"name":"Tom Mason","character":"Supply Sergeant","img":null,"order":13},{"name":"Cynthia Wood","character":"Playmate of the Year","img":null,"order":14}],"movie:329865":[{"name":"Amy Adams","character":"Louise Banks","img":null,"order":0},{"name":"Jeremy Renner","character":"Ian Donnelly","img":null,"order":1},{"name":"Forest Whitaker","character":"Colonel Weber","img":null,"order":2},{"name":"Michael Stuhlbarg","character":"Agent Halpern","img":null,"order":3},{"name":"Mark O'Brien","character":"Captain Marks","img":null,"order":4},{"name":"Tzi Ma","character":"General Shang","img":null,"order":5},{"name":"Abigail Pniowsky","character":"8-Year-Old Hannah","img":null,"order":6},{"name":"Julia Scarlett Dan","character":"12-Year-Old Hannah","img":null,"order":7},{"name":"Jadyn Malone","character":"6-Year-Old Hannah","img":null,"order":8},{"name":"Frank Schorpion","character":"Dr. Kettler","img":null,"order":9},{"name":"Lucas Chartier-Dessert","character":"Private Lasky","img":null,"order":10},{"name":"Christian Jadah","character":"Private Combs","img":null,"order":11},{"name":"Lucy Van Oldenbarneveld","character":"CNAC Anchor","img":null,"order":12},{"name":"Andrew Shaver","character":"Environmental Tech","img":null,"order":13},{"name":"Pat Kiely","character":"Environmental Tech","img":null,"order":14}],"movie:980431":[{"name":"Eric Nam","character":"Aang (voice)","img":null,"order":0},{"name":"Dave Bautista","character":"Tagah (voice)","img":null,"order":1},{"name":"Jessica Matten","character":"Katara (voice)","img":null,"order":2},{"name":"Rom\u00e1n Zaragoza","character":"Sokka (voice)","img":null,"order":3},{"name":"Steven Yeun","character":"Zuko (voice)","img":null,"order":4},{"name":"Dionne Quan","character":"Toph (voice)","img":null,"order":5},{"name":"Dee Bradley Baker","character":"Appa / Momo (voice)","img":null,"order":6},{"name":"Freida Pinto","character":"Avatar Sonam (voice)","img":null,"order":7},{"name":"Ke Huy Quan","character":"Avatar Xian (voice)","img":null,"order":8},{"name":"Taika Waititi","character":"Gorillavark (voice)","img":null,"order":9},{"name":"Geraldine Viswanathan","character":"Kallik (voice)","img":null,"order":10},{"name":"Peta Sergeant","character":"Jie (voice)","img":null,"order":11},{"name":"Jonathan Ohye","character":"Yuddah (voice)","img":null,"order":12},{"name":"Vic Chao","character":"Hong (voice)","img":null,"order":13},{"name":"Fran\u00e7ois Chau","character":"Monk Gyatso (voice)","img":null,"order":14}],"movie:424694":[{"name":"Rami Malek","character":"Freddie Mercury","img":null,"order":0},{"name":"Gwilym Lee","character":"Brian May","img":null,"order":1},{"name":"Ben Hardy","character":"Roger Taylor","img":null,"order":2},{"name":"Joseph Mazzello","character":"John Deacon","img":null,"order":3},{"name":"Lucy Boynton","character":"Mary Austin","img":null,"order":4},{"name":"Aidan Gillen","character":"John Reid","img":null,"order":5},{"name":"Allen Leech","character":"Paul Prenter","img":null,"order":6},{"name":"Tom Hollander","character":"Jim Beach","img":null,"order":7},{"name":"Mike Myers","character":"Ray Foster","img":null,"order":8},{"name":"Aaron McCusker","character":"Jim Hutton","img":null,"order":9},{"name":"Meneka Das","character":"Jer Bulsara","img":null,"order":10},{"name":"Ace Bhatti","character":"Bomi Bulsara","img":null,"order":11},{"name":"Priya Blackburn","character":"Kashmira Bulsara","img":null,"order":12},{"name":"Max Bennett","character":"David","img":null,"order":13},{"name":"Dermot Murphy","character":"Bob Geldof","img":null,"order":14}],"movie:508883":[{"name":"Soma Santoki","character":"Mahito Maki (voice)","img":null,"order":0},{"name":"Masaki Suda","character":"Gray Heron (voice)","img":null,"order":1},{"name":"Ko Shibasaki","character":"Kiriko (voice)","img":null,"order":2},{"name":"Aimyon","character":"Lady Himi (voice)","img":null,"order":3},{"name":"Yoshino Kimura","character":"Natsuko (voice)","img":null,"order":4},{"name":"Takuya Kimura","character":"Shoichi Maki (voice)","img":null,"order":5},{"name":"Keiko Takeshita","character":"Izumi (voice)","img":null,"order":6},{"name":"Jun Fubuki","character":"Utako (voice)","img":null,"order":7},{"name":"Sawako Agawa","character":"Eriko (voice)","img":null,"order":8},{"name":"Karen Takizawa","character":"Warawara (voice)","img":null,"order":9},{"name":"Shinobu Otake","character":"Aiko (voice)","img":null,"order":10},{"name":"Jun Kunimura","character":"The Parakeet King (voice)","img":null,"order":11},{"name":"Kaoru Kobayashi","character":"Noble Pelican (voice)","img":null,"order":12},{"name":"Sh\u014dhei Hino","character":"Granduncle (voice)","img":null,"order":13}],"movie:197":[{"name":"Mel Gibson","character":"William Wallace","img":null,"order":0},{"name":"Catherine McCormack","character":"Murron MacClannough","img":null,"order":1},{"name":"Sophie Marceau","character":"Princess Isabelle","img":null,"order":2},{"name":"Patrick McGoohan","character":"King Edward","img":null,"order":3},{"name":"Angus Macfadyen","character":"Robert the Bruce","img":null,"order":4},{"name":"Brendan Gleeson","character":"Hamish Campbell","img":null,"order":5},{"name":"James Robinson","character":"Young William Wallace","img":null,"order":6},{"name":"James Cosmo","character":"Campbell","img":null,"order":7},{"name":"Sean McGinley","character":"MacClannough","img":null,"order":8},{"name":"Gerda Stevenson","character":"Mother MacClannough","img":null,"order":9},{"name":"Mhairi Calvey","character":"Young Murron MacClannough","img":null,"order":10},{"name":"Jeanne Marine","character":"Nicolette","img":null,"order":11},{"name":"Sean Lawlor","character":"Malcolm Wallace","img":null,"order":12},{"name":"Sandy Nelson","character":"John Wallace","img":null,"order":13},{"name":"Alan Tall","character":"Elder Stewart","img":null,"order":14}],"movie:2108":[{"name":"Emilio Estevez","character":"Andrew Clark","img":null,"order":0},{"name":"Judd Nelson","character":"John Bender","img":null,"order":1},{"name":"Molly Ringwald","character":"Claire Standish","img":null,"order":2},{"name":"Anthony Michael Hall","character":"Brian Johnson","img":null,"order":3},{"name":"Ally Sheedy","character":"Allison Reynolds","img":null,"order":4},{"name":"Paul Gleason","character":"Richard Vernon","img":null,"order":5},{"name":"John Kapelos","character":"Carl","img":null,"order":6},{"name":"Perry Crawford","character":"Allison's Father","img":null,"order":7},{"name":"Mary Christian","character":"Brian's Sister","img":null,"order":8},{"name":"Ron Dean","character":"Andy's Father","img":null,"order":9},{"name":"Tim Gamble","character":"Claire's Father","img":null,"order":10},{"name":"Fran Gargano","character":"Allison's Mom","img":null,"order":11},{"name":"Mercedes Hall","character":"Brian's Mom","img":null,"order":12},{"name":"John Hughes","character":"Brian\u2019s Father (uncredited)","img":null,"order":13},{"name":"Jonathan Chapin","character":"Janitor (uncredited)","img":null,"order":14}],"movie:10515":[{"name":"Keiko Yokozawa","character":"Sheeta (voice)","img":null,"order":0},{"name":"Mayumi Tanaka","character":"Pazu (voice)","img":null,"order":1},{"name":"Minori Terada","character":"Muska (voice)","img":null,"order":2},{"name":"Kotoe Hatsui","character":"Dola (voice)","img":null,"order":3},{"name":"Fujio Tokita","character":"Uncle Pomme (voice)","img":null,"order":4},{"name":"Ichiro Nagai","character":"Shogun Mouro (voice)","img":null,"order":5},{"name":"Hiroshi Ito","character":"Oyakata (voice)","img":null,"order":6},{"name":"Machiko Washio","character":"Okami (voice)","img":null,"order":7},{"name":"Takuz\u014d Kamiyama","character":"Charles / Shalulu (voice)","img":null,"order":8},{"name":"Yoshito Yasuhara","character":"Louis (voice)","img":null,"order":9},{"name":"Sukekiyo Kameyama","character":"Henri (voice)","img":null,"order":10},{"name":"Eken Mine","character":"Underling (voice)","img":null,"order":11},{"name":"TARAKO","character":"Madge (voice)","img":null,"order":12},{"name":"Masashi Sugawara","character":"Underling (voice)","img":null,"order":13},{"name":"Reiko Suzuki","character":"Sheeta's Grandmother (voice)","img":null,"order":14}],"movie:15":[{"name":"Orson Welles","character":"Charles Foster Kane","img":null,"order":0},{"name":"Joseph Cotten","character":"Jedediah Leland","img":null,"order":1},{"name":"Dorothy Comingore","character":"Susan Alexander Kane","img":null,"order":2},{"name":"Ray Collins","character":"Jim W. Gettys","img":null,"order":3},{"name":"George Coulouris","character":"Walter Parks Thatcher","img":null,"order":4},{"name":"Agnes Moorehead","character":"Mary Kane","img":null,"order":5},{"name":"Paul Stewart","character":"Raymond","img":null,"order":6},{"name":"Ruth Warrick","character":"Emily Norton Kane","img":null,"order":7},{"name":"Erskine Sanford","character":"Herbert Carter","img":null,"order":8},{"name":"William Alland","character":"Jerry Thompson/Narrator","img":null,"order":9},{"name":"Everett Sloane","character":"Mr. Bernstein","img":null,"order":10},{"name":"Fortunio Bonanova","character":"Signor Matiste","img":null,"order":11},{"name":"Gus Schilling","character":"John","img":null,"order":12},{"name":"Philip Van Zandt","character":"Mr. Rawlston","img":null,"order":13},{"name":"Georgia Backus","character":"Bertha Anderson","img":null,"order":14}],"movie:974576":[{"name":"Ralph Fiennes","character":"Lawrence","img":null,"order":0},{"name":"Stanley Tucci","character":"Bellini","img":null,"order":1},{"name":"John Lithgow","character":"Tremblay","img":null,"order":2},{"name":"Isabella Rossellini","character":"Sister Agnes","img":null,"order":3},{"name":"Lucian Msamati","character":"Adeyemi","img":null,"order":4},{"name":"Carlos Diehz","character":"Benitez","img":null,"order":5},{"name":"Sergio Castellitto","character":"Tedesco","img":null,"order":6},{"name":"Br\u00edan F. O'Byrne","character":"O'Malley","img":null,"order":7},{"name":"Merab Ninidze","character":"Sabbadin","img":null,"order":8},{"name":"Thomas Loibl","character":"Mandorff","img":null,"order":9},{"name":"Jacek Koman","character":"Wozniak","img":null,"order":10},{"name":"Bruno Novelli","character":"Dead Pope","img":null,"order":11},{"name":"Rony Kramer","character":"Mendoza","img":null,"order":12},{"name":"Valerio Da Silva","character":"Father Haas","img":null,"order":13},{"name":"Joseph Mydell","character":"Nakitanda","img":null,"order":14}],"movie:567797":[{"name":"Eric Bana","character":"Aaron Falk","img":null,"order":0},{"name":"Genevieve O'Reilly","character":"Gretchen Schoner","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":1},{"name":"Keir O'Donnell","character":"Greg Raco","img":null,"order":2},{"name":"John Polson","character":"Scott Whitlam","img":null,"order":3},{"name":"Matt Nable","character":"Grant Dow","img":null,"order":4},{"name":"Eddie Baroo","character":"McMurdo","img":null,"order":5},{"name":"Martin Dingle Wall","character":"Luke Hadler","img":null,"order":6},{"name":"Julia Blake","character":"Barb Hadler","img":null,"order":7},{"name":"Bruce Spence","character":"Gerry Hadler","img":null,"order":8},{"name":"BeBe Bettencourt","character":"Ellie Deacon","img":null,"order":9},{"name":"Jeremy Lindsay Taylor","character":"Erik Falk","img":null,"order":10},{"name":"Miranda Tapsell","character":"Rita Raco","img":null,"order":11},{"name":"Renee Lim","character":"Sandra Whitlam","img":null,"order":12},{"name":"James Frecheville","character":"Sullivan","img":null,"order":13},{"name":"William Zappa","character":"Mal Deacon","img":null,"order":14}],"movie:37735":[{"name":"Emma Stone","character":"Olive","img":null,"order":0},{"name":"Penn Badgley","character":"Woodchuck Todd","img":null,"order":1},{"name":"Amanda Bynes","character":"Marianne","img":null,"order":2},{"name":"Dan Byrd","character":"Brandon","img":null,"order":3},{"name":"Thomas Haden Church","character":"Mr. Griffith","img":null,"order":4},{"name":"Patricia Clarkson","character":"Rosemary","img":null,"order":5},{"name":"Cam Gigandet","character":"Micah","img":null,"order":6},{"name":"Lisa Kudrow","character":"Mrs. Griffith","img":null,"order":7},{"name":"Malcolm McDowell","character":"Principal Gibbons","img":null,"order":8},{"name":"Aly Michalka","character":"Rhiannon","img":null,"order":9},{"name":"Stanley Tucci","character":"Dill","img":null,"order":10},{"name":"Fred Armisen","character":"Pastor","img":null,"order":11},{"name":"Juliette Goglia","character":"Eighth Grade Olive","img":null,"order":12},{"name":"Jake Sandvig","character":"Anson","img":null,"order":13},{"name":"Morgan Rusler","character":"Mr. Abernathy","img":null,"order":14}],"movie:559969":[{"name":"Aaron Paul","character":"Jesse","img":null,"order":0},{"name":"Jesse Plemons","character":"Todd","img":null,"order":1},{"name":"Charles Baker","character":"Skinny Pete","img":null,"order":2},{"name":"Matt Jones","character":"Badger","img":null,"order":3},{"name":"Scott MacArthur","character":"Neil","img":null,"order":4},{"name":"Larry Hankin","character":"Old Joe","img":null,"order":5},{"name":"Scott Shepherd","character":"Casey","img":null,"order":6},{"name":"Tom Bower","character":"Lou","img":null,"order":7},{"name":"Robert Forster","character":"Ed","img":null,"order":8},{"name":"Jonathan Banks","character":"Mike","img":null,"order":9},{"name":"Bryan Cranston","character":"Walt","img":null,"order":10},{"name":"Krysten Ritter","character":"Jane","img":null,"order":11},{"name":"Kevin Rankin","character":"Kenny","img":null,"order":12},{"name":"Tess Harper","character":"Mrs. Pinkman","img":null,"order":13},{"name":"Michael Bofshever","character":"Mr. Pinkman","img":null,"order":14}],"movie:715931":[{"name":"Will Smith","character":"Peter","img":null,"order":0},{"name":"Ben Foster","character":"Jim Fassel","img":null,"order":1},{"name":"Charmaine Bingwa","character":"Dodienne","img":null,"order":2},{"name":"Gilbert Owuor","character":"Gordon","img":null,"order":3},{"name":"Ronnie Gene Blevins","character":"Harrington","img":null,"order":4},{"name":"Aaron Moten","character":"Knowls","img":null,"order":5},{"name":"Jabbar Lewis","character":"Tomas","img":null,"order":6},{"name":"Michael Luwoye","character":"John","img":null,"order":7},{"name":"Steven Ogg","character":"Howard","img":null,"order":8},{"name":"Grant Harvey","character":"Leeds","img":null,"order":9},{"name":"Mustafa Shakir","character":"Lt. Andrew Cailloux","img":null,"order":10},{"name":"Paul Ben-Victor","character":"Major G. Halstead","img":null,"order":11},{"name":"David Denman","character":"General William Dwight","img":null,"order":12},{"name":"Jesse C. Boyd","character":"Mike Hurley","img":null,"order":13},{"name":"Imani Pullum","character":"Betsy","img":null,"order":14}],"movie:445629":[{"name":"Florence Pugh","character":"Saraya 'Paige' Knight","img":null,"order":0},{"name":"Lena Headey","character":"Julia 'Sweet Saraya' Knight","img":null,"order":1},{"name":"Nick Frost","character":"Patrick 'Rowdy Ricky' Knight","img":null,"order":2},{"name":"Vince Vaughn","character":"Hutch Morgan","img":null,"order":3},{"name":"Jack Lowden","character":"Zak 'Zodiac' Knight","img":null,"order":4},{"name":"Dwayne Johnson","character":"Dwayne 'The Rock' Johnson","img":null,"order":5},{"name":"Thea Trinidad","character":"AJ Lee","img":null,"order":6},{"name":"Aqueela Zoll","character":"Kirsten","img":null,"order":7},{"name":"Stephen Merchant","character":"Hugh","img":null,"order":8},{"name":"Julia Davis","character":"Daphne","img":null,"order":9},{"name":"James Burrows","character":"Roy Knight","img":null,"order":10},{"name":"Hannah Rae","character":"Courtney","img":null,"order":11},{"name":"Kim Matula","character":"Jeri-Lynn","img":null,"order":12},{"name":"Paul Wight","character":"Big Show","img":null,"order":13},{"name":"Stephen Farrelly","character":"Sheamus","img":null,"order":14}],"movie:832262":[{"name":"Eric Bana","character":"Aaron Falk","img":null,"order":0},{"name":"Anna Torv","character":"Alice","img":null,"order":1},{"name":"Deborra-Lee Furness","character":"Jill Bailey","img":null,"order":2},{"name":"Robin McLeavy","character":"Lauren","img":null,"order":3},{"name":"Sisi Stringer","character":"Beth","img":null,"order":4},{"name":"Lucy Ansell","character":"Bree","img":null,"order":5},{"name":"Jacqueline McKenzie","character":"Carmen Cooper","img":null,"order":6},{"name":"Jeremy Lindsay Taylor","character":"Erik Falk","img":null,"order":7},{"name":"Richard Roxburgh","character":"Daniel Bailey","img":null,"order":8},{"name":"Tony Briggs","character":"Ian Chase","img":null,"order":9},{"name":"Kenneth Radley","character":"Sergeant King","img":null,"order":10},{"name":"Ash Ricardo","character":"Jennifer Falk","img":null,"order":11},{"name":"Ingrid Torelli","character":"Margot","img":null,"order":12},{"name":"Matilda May Pawsey","character":"Rebecca","img":null,"order":13},{"name":"Archie Thomson","character":"Aaron Falk","img":null,"order":14}],"movie:316152":[{"name":"Matthew McConaughey","character":"Newton Knight","img":null,"order":0},{"name":"Gugu Mbatha-Raw","character":"Rachel","img":null,"order":1},{"name":"Mahershala Ali","character":"Moses Washington","img":null,"order":2},{"name":"Keri Russell","character":"Serena Knight","img":null,"order":3},{"name":"Jacob Lofland","character":"Daniel","img":null,"order":4},{"name":"Sean Bridgers","character":"Sumrall","img":null,"order":5},{"name":"Brad Carter","character":"Lieutenant Barbour","img":null,"order":6},{"name":"Jane McNeill","character":"Miss Ellie","img":null,"order":7},{"name":"Gary Grubbs","character":"Prosecuting Attorney","img":null,"order":8},{"name":"Christopher Berry","character":"Jasper","img":null,"order":9},{"name":"Joe Chrest","character":"Amos Deason","img":null,"order":10},{"name":"David Jensen","character":"Quitman","img":null,"order":11},{"name":"Kurt Krause","character":"Injured Soldier","img":null,"order":12},{"name":"Carlton Caudle","character":"Confederate Color Guard","img":null,"order":13},{"name":"Martin Bats Bradford","character":"Freedman 1","img":null,"order":14}],"movie:83389":[{"name":"Masami Nagasawa","character":"Umi Matsuzaki (voice)","img":null,"order":0},{"name":"Junichi Okada","character":"Shun Kazama / Yuichiro Sawamura (voice)","img":null,"order":1},{"name":"Keiko Takeshita","character":"Hana Matsuzaki (voice)","img":null,"order":2},{"name":"Yuriko Ishida","character":"Miki Hokuto (voice)","img":null,"order":3},{"name":"Rumi Hiiragi","character":"Sachiko Hirokoji (voice)","img":null,"order":4},{"name":"Jun Fubuki","character":"Ryoko Matsuzaki (voice)","img":null,"order":5},{"name":"Takashi Naito","character":"Yoshio Onodera (voice)","img":null,"order":6},{"name":"Shunsuke Kazama","character":"Shiro Mizunuma / Hiroshi Tachibana (voice)","img":null,"order":7},{"name":"Nao Omori","character":"Akio Kazama (voice)","img":null,"order":8},{"name":"Teruyuki Kagawa","character":"Tokumaru Board Chairman (voice)","img":null,"order":9},{"name":"Haruka Shiraishi","character":"Sora Matsuzaki (voice)","img":null,"order":10},{"name":"Tsubasa Kobayashi","character":"Riku Matsuzaki (voice)","img":null,"order":11},{"name":"Ayako Ito","character":"Secretary to the Tokumaru Board Chairman (voice)","img":null,"order":12},{"name":"Naoya Fujimaki","character":"Tokumaru Front Desk Receptionist (voice)","img":null,"order":13},{"name":"Toshimi Kanno","character":"Nobuko (voice)","img":null,"order":14}],"movie:9323":[{"name":"Atsuko Tanaka","character":"Motoko Kusanagi (voice)","img":null,"order":0},{"name":"Akio Otsuka","character":"Batou (voice)","img":null,"order":1},{"name":"Iemasa Kayumi","character":"Puppetmaster (voice)","img":null,"order":2},{"name":"Koichi Yamadera","character":"Togusa (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":3},{"name":"Yutaka Nakano","character":"Ishikawa (voice)","img":null,"order":4},{"name":"Tamio Ohki","character":"Aramaki (voice)","img":null,"order":5},{"name":"Tessyo Genda","character":"Director Nakamura (voice)","img":null,"order":6},{"name":"Masakazu Namaki","character":"Dr. Willis (voice)","img":null,"order":7},{"name":"Masato Yamanouchi","character":"Minister of Foreign Affairs (voice)","img":null,"order":8},{"name":"Shinji Ogawa","character":"Gaik\u00f4kan (voice)","img":null,"order":9},{"name":"Mitsuru Miyamoto","character":"Mizuho Daita (voice)","img":null,"order":10},{"name":"Kazuhiro Yamaji","character":"Garbage Collector A (voice)","img":null,"order":11},{"name":"Shigeru Chiba","character":"Garbage Collector B (voice)","img":"mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","order":12},{"name":"Hiroshi Yanaka","character":"Coroner (voice)","img":null,"order":13},{"name":"Ginzo Matsuo","character":"Old Man (voice)","img":null,"order":14}],"movie:12140":[{"name":"Atsuko Tanaka","character":"Motoko Kusanagi (voice)","img":null,"order":0},{"name":"Akio Otsuka","character":"Batou (voice)","img":null,"order":1},{"name":"Koichi Yamadera","character":"Togusa (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":2},{"name":"Tamio Ohki","character":"Section 9 Department Chief Aramaki (voice)","img":null,"order":3},{"name":"Yutaka Nakano","character":"Ishikawa (voice)","img":null,"order":4},{"name":"Hiroaki Hirata","character":"Koga (voice)","img":null,"order":5},{"name":"Masaki Terasoma","character":"Azuma (voice)","img":null,"order":6},{"name":"Sumi Mutoh","character":"Mysterious Woman (voice)","img":null,"order":7},{"name":"Yuzuru Fujimoto","character":"Criminal (voice)","img":null,"order":8},{"name":"Sukekiyo Kameyama","character":"Rin (voice)","img":null,"order":9},{"name":"Ryuji Nakagi","character":"Wakabayashi (voice)","img":null,"order":10},{"name":"Fumihiko Tachiki","character":"Bodyguard (voice)","img":null,"order":11},{"name":"Minoru Hirano","character":"Cyborg Doctor (voice)","img":null,"order":12},{"name":"Katsunosuke Hori","character":"Chief of Forensics Division (voice)","img":null,"order":13},{"name":"Naoto Takenaka","character":"Kim (voice)","img":null,"order":14}],"movie:238":[{"name":"Marlon Brando","character":"Don Vito Corleone","img":null,"order":0},{"name":"Al Pacino","character":"Michael Corleone","img":null,"order":1},{"name":"James Caan","character":"Sonny Corleone","img":null,"order":2},{"name":"Robert Duvall","character":"Tom Hagen","img":null,"order":3},{"name":"Richard S. Castellano","character":"Clemenza","img":null,"order":4},{"name":"Diane Keaton","character":"Kay Adams","img":null,"order":5},{"name":"Talia Shire","character":"Connie Corleone Rizzi","img":null,"order":6},{"name":"Gianni Russo","character":"Carlo Rizzi","img":null,"order":7},{"name":"Sterling Hayden","character":"Captain McCluskey","img":null,"order":8},{"name":"John Marley","character":"Jack Woltz","img":null,"order":9},{"name":"Richard Conte","character":"Barzini","img":null,"order":10},{"name":"Al Lettieri","character":"Sollozzo","img":null,"order":11},{"name":"Abe Vigoda","character":"Sal Tessio","img":null,"order":12},{"name":"John Cazale","character":"Fredo Corleone","img":null,"order":13},{"name":"Rudy Bond","character":"Cuneo","img":null,"order":14}],"movie:240":[{"name":"Al Pacino","character":"Don Michael Corleone","img":null,"order":0},{"name":"Robert Duvall","character":"Tom Hagen","img":null,"order":1},{"name":"Diane Keaton","character":"Kay Corleone","img":null,"order":2},{"name":"Robert De Niro","character":"Vito Corleone","img":null,"order":3},{"name":"John Cazale","character":"Frederico 'Fredo' Corleone","img":null,"order":4},{"name":"Talia Shire","character":"Constanzia 'Connie' Corleone","img":null,"order":5},{"name":"Lee Strasberg","character":"Hyman Roth","img":null,"order":6},{"name":"Michael V. Gazzo","character":"Frank Pentangeli","img":null,"order":7},{"name":"G.D. Spradlin","character":"Senator Pat Geary","img":null,"order":8},{"name":"Richard Bright","character":"Al Neri","img":null,"order":9},{"name":"Gastone Moschin","character":"Don Fanucci","img":null,"order":10},{"name":"Tom Rosqui","character":"Rocco Lampone","img":null,"order":11},{"name":"Bruno Kirby","character":"Young Clemenza","img":null,"order":12},{"name":"Frank Sivero","character":"Genco Abbandando","img":null,"order":13},{"name":"Francesca De Sapio","character":"Young Mama Corleone","img":null,"order":14}],"movie:242":[{"name":"Al Pacino","character":"Michael Corleone","img":null,"order":0},{"name":"Diane Keaton","character":"Kay Adams","img":null,"order":1},{"name":"Talia Shire","character":"Connie Corleone Rizzi","img":null,"order":2},{"name":"Andy Garcia","character":"Vincent Mancini","img":null,"order":3},{"name":"Eli Wallach","character":"Don Altobello","img":null,"order":4},{"name":"Joe Mantegna","character":"Joey Zasa","img":null,"order":5},{"name":"George Hamilton","character":"B.J. Harrison","img":null,"order":6},{"name":"Bridget Fonda","character":"Grace Hamilton","img":null,"order":7},{"name":"Sofia Coppola","character":"Mary Corleone","img":null,"order":8},{"name":"Raf Vallone","character":"Cardinal Lamberto","img":null,"order":9},{"name":"Franc D'Ambrosio","character":"Anthony Corleone","img":null,"order":10},{"name":"Donal Donnelly","character":"Archbishop Gilday","img":null,"order":11},{"name":"Richard Bright","character":"Al Neri","img":null,"order":12},{"name":"Helmut Berger","character":"Frederick Keinszig","img":null,"order":13},{"name":"Don Novello","character":"Dominic Abbandando","img":null,"order":14}],"movie:5279":[{"name":"Maggie Smith","character":"Constance Trentham","img":null,"order":0},{"name":"Michael Gambon","character":"William McCordle","img":null,"order":1},{"name":"Kristin Scott Thomas","character":"Sylvia McCordle","img":null,"order":2},{"name":"Camilla Rutherford","character":"Isobel McCordle","img":null,"order":3},{"name":"Charles Dance","character":"Raymond Stockbridge","img":null,"order":4},{"name":"Geraldine Somerville","character":"Louisa Stockbridge","img":null,"order":5},{"name":"Tom Hollander","character":"Anthony Meredith","img":null,"order":6},{"name":"Natasha Wightman","character":"Lavinia Meredith","img":null,"order":7},{"name":"Jeremy Northam","character":"Ivor Novello","img":null,"order":8},{"name":"Bob Balaban","character":"Morris Weissman","img":null,"order":9},{"name":"James Wilby","character":"Freddie Nesbitt","img":null,"order":10},{"name":"Claudie Blakley","character":"Mabel Nesbitt","img":null,"order":11},{"name":"Laurence Fox","character":"Rupert Standish","img":null,"order":12},{"name":"Trent Ford","character":"Jeremy Blond","img":null,"order":13},{"name":"Ryan Phillippe","character":"Henry Denton","img":null,"order":14}],"movie:12477":[{"name":"Tsutomu Tatsumi","character":"Seita (voice)","img":null,"order":0},{"name":"Ayano Shiraishi","character":"Setsuko (voice)","img":null,"order":1},{"name":"Yoshiko Shinohara","character":"Mother (voice)","img":null,"order":2},{"name":"Akemi Yamaguchi","character":"Auntie (voice)","img":null,"order":3},{"name":"Masayo Sakai","character":"Woman who takes care of Setsuko (voice)","img":null,"order":4},{"name":"Kozo Hashida","character":"Obayashi Chairman (voice)","img":null,"order":5},{"name":"Kazumi Nozaki","character":"Cousin (voice)","img":null,"order":6},{"name":"Yoshio Matsuoka","character":"Gosaku (voice)","img":null,"order":7},{"name":"Masahiro Kanetake","character":"Aunt's House Guest (voice)","img":null,"order":8},{"name":"Kiyoshi Yanagawa","character":"Patrolman (voice)","img":null,"order":9},{"name":"Hajime Maki","character":"Man who arrests Seita (voice)","img":null,"order":10},{"name":"Atsuo Omotem","character":"Person in Bank (voice)","img":null,"order":11},{"name":"Teruhisa Harita","character":"Station Worker (voice)","img":null,"order":12},{"name":"Hiroshi Tanaka","character":"Person in Bank (voice)","img":null,"order":13},{"name":"Michio Denpo","character":"Station Worker / Doctor (voice)","img":null,"order":14}],"movie:64682":[{"name":"Leonardo DiCaprio","character":"Jay Gatsby","img":null,"order":0},{"name":"Tobey Maguire","character":"Nick Carraway","img":null,"order":1},{"name":"Carey Mulligan","character":"Daisy Buchanan","img":null,"order":2},{"name":"Joel Edgerton","character":"Tom Buchanan","img":null,"order":3},{"name":"Elizabeth Debicki","character":"Jordan Baker","img":null,"order":4},{"name":"Isla Fisher","character":"Myrtle Wilson","img":null,"order":5},{"name":"Jason Clarke","character":"George Wilson","img":null,"order":6},{"name":"Amitabh Bachchan","character":"Meyer Wolfsheim","img":null,"order":7},{"name":"Callan McAuliffe","character":"Teen Jay Gatsby","img":null,"order":8},{"name":"Adelaide Clemens","character":"Catherine","img":null,"order":9},{"name":"Steve Bisley","character":"Dan Cody","img":null,"order":10},{"name":"Richard Carter","character":"Herzog","img":null,"order":11},{"name":"Jack Thompson","character":"Dr. Walter Perkins","img":null,"order":12},{"name":"Vince Colosimo","character":"Michaelis","img":null,"order":13},{"name":"Max Cullen","character":"Owl Eyes","img":null,"order":14}],"movie:590":[{"name":"Nicole Kidman","character":"Virginia Woolf","img":null,"order":0},{"name":"Julianne Moore","character":"Laura Brown","img":null,"order":1},{"name":"Meryl Streep","character":"Clarissa Vaughan","img":null,"order":2},{"name":"Stephen Dillane","character":"Leonard Woolf","img":null,"order":3},{"name":"Jack Rovello","character":"Richie","img":null,"order":4},{"name":"Ed Harris","character":"Richard Brown","img":null,"order":5},{"name":"Miranda Richardson","character":"Vanessa Bell","img":null,"order":6},{"name":"John C. Reilly","character":"Dan Brown","img":null,"order":7},{"name":"Allison Janney","character":"Sally Lester","img":null,"order":8},{"name":"George Loftus","character":"Quentin Bell","img":null,"order":9},{"name":"Charley Ramm","character":"Julian Bell","img":null,"order":10},{"name":"Sophie Wyburd","character":"Angelica Bell","img":null,"order":11},{"name":"Lyndsey Marshal","character":"Lottie Hope","img":null,"order":12},{"name":"Linda Bassett","character":"Nelly Boxall","img":null,"order":13},{"name":"Christian Coulson","character":"Ralph Partridge","img":null,"order":14}],"movie:4935":[{"name":"Chieko Baisho","character":"Sophie (voice)","img":null,"order":0},{"name":"Takuya Kimura","character":"Howl (voice)","img":null,"order":1},{"name":"Akihiro Miwa","character":"Witch of the Waste (voice)","img":null,"order":2},{"name":"Tatsuya Gashuin","character":"Calcifer (voice)","img":null,"order":3},{"name":"Ryunosuke Kamiki","character":"Markl (voice)","img":null,"order":4},{"name":"Mitsunori Isaki","character":"Kosho (voice)","img":null,"order":5},{"name":"Yo Oizumi","character":"Prince Justin / Turnip Head (voice)","img":null,"order":6},{"name":"Akio Otsuka","character":"King of Ingary (voice)","img":null,"order":7},{"name":"Daijir\u014d Harada","character":"Heen (voice)","img":null,"order":8},{"name":"Haruko Kat\u014d","character":"Madame Suliman (voice)","img":null,"order":9},{"name":"Makoto Yasumura","character":"(voice)","img":null,"order":10},{"name":"Manabu Muraji","character":"(voice)","img":null,"order":11},{"name":"Keiko Tsukamoto","character":"(voice)","img":null,"order":12},{"name":"Tsuzuki Kayako","character":"(voice)","img":null,"order":13},{"name":"Seiji Sasaki","character":"(voice)","img":null,"order":14}],"movie:467909":[{"name":"Anthony Ramos","character":"Usnavi","img":null,"order":0},{"name":"Corey Hawkins","character":"Benny","img":null,"order":1},{"name":"Leslie Grace","character":"Nina Rosario","img":null,"order":2},{"name":"Melissa Barrera","character":"Vanessa","img":null,"order":3},{"name":"Olga Merediz","character":"Abuela Claudia","img":null,"order":4},{"name":"Daphne Rubin-Vega","character":"Daniela","img":null,"order":5},{"name":"Gregory D\u00edaz IV","character":"Sonny","img":null,"order":6},{"name":"Marc Anthony","character":"Gapo","img":null,"order":7},{"name":"Jimmy Smits","character":"Kevin Rosario","img":null,"order":8},{"name":"Stephanie Beatriz","character":"Carla","img":null,"order":9},{"name":"Dascha Polanco","character":"Cuca","img":null,"order":10},{"name":"Noah Catala","character":"Graffiti Pete","img":null,"order":11},{"name":"Lin-Manuel Miranda","character":"Pirag\u00fcero","img":null,"order":12},{"name":"Mateo G\u00f3mez","character":"Alejandro","img":null,"order":13},{"name":"Patrick Page","character":"Pike","img":null,"order":14}],"movie:19994":[{"name":"Megan Fox","character":"Jennifer","img":null,"order":0},{"name":"Amanda Seyfried","character":"Needy","img":null,"order":1},{"name":"Johnny Simmons","character":"Chip","img":null,"order":2},{"name":"Adam Brody","character":"Nikolai","img":null,"order":3},{"name":"Sal Cortez","character":"Chas","img":null,"order":4},{"name":"Ryan Levine","character":"Mick","img":null,"order":5},{"name":"Juan Riedinger","character":"Dirk","img":null,"order":6},{"name":"Colin Askey","character":"Keyboardist","img":null,"order":7},{"name":"Chris Pratt","character":"Roman Duda","img":null,"order":8},{"name":"Juno Rinaldi","character":"Officer Warzak","img":null,"order":9},{"name":"Kyle Gallner","character":"Colin Gray","img":null,"order":10},{"name":"Josh Emerson","character":"Jonas Kozelle","img":null,"order":11},{"name":"J.K. Simmons","character":"Mr. Wroblewski","img":null,"order":12},{"name":"Amy Sedaris","character":"Needy's Mom","img":null,"order":13},{"name":"Cynthia Stevenson","character":"Chip's Mom","img":null,"order":14}],"movie:16859":[{"name":"Minami Takayama","character":"Kiki / Ursula (voice)","img":null,"order":0},{"name":"Rei Sakuma","character":"Jiji (voice)","img":null,"order":1},{"name":"Kappei Yamaguchi","character":"Tombo (voice)","img":null,"order":2},{"name":"Keiko Toda","character":"Osono (voice)","img":null,"order":3},{"name":"Mieko Nobusawa","character":"Kokiri (voice)","img":null,"order":4},{"name":"Koichi Miura","character":"Okino (voice)","img":null,"order":5},{"name":"Haruko Kat\u014d","character":"Old Lady (voice)","img":null,"order":6},{"name":"Hiroko Seki","character":"Bertha (voice)","img":null,"order":7},{"name":"Yuriko Fuchizaki","character":"Ketto (voice)","img":null,"order":8},{"name":"Koichi Yamadera","character":"Bakery Worker / Policeman (voice)","img":"biov5jENp2XYfehkcdnRvi796Wi.jpg","order":9},{"name":"Kikuko Inoue","character":"Maki (voice)","img":null,"order":10},{"name":"Yuko Kobayashi","character":"Young Senpai Witch (voice)","img":null,"order":11},{"name":"Mika Doi","character":"Ketto's Mother (voice)","img":null,"order":12},{"name":"Takaya Hashi","character":"Ketto's Father (voice)","img":null,"order":13},{"name":"Chika Sakamoto","character":"Baby (voice)","img":null,"order":14}],"movie:9361":[{"name":"Daniel Day-Lewis","character":"Hawkeye","img":null,"order":0},{"name":"Madeleine Stowe","character":"Cora","img":null,"order":1},{"name":"Jodhi May","character":"Alice","img":null,"order":2},{"name":"Russell Means","character":"Chingachgook","img":null,"order":3},{"name":"Wes Studi","character":"Magua","img":null,"order":4},{"name":"Eric Schweig","character":"Uncas","img":null,"order":5},{"name":"Steven Waddington","character":"Heyward","img":null,"order":6},{"name":"Maurice Ro\u00ebves","character":"Colonel Munro","img":null,"order":7},{"name":"Pete Postlethwaite","character":"Captain Beams","img":null,"order":8},{"name":"Colm Meaney","character":"Major Ambrose","img":null,"order":9},{"name":"Patrice Ch\u00e9reau","character":"General Montcalm","img":null,"order":10},{"name":"Edward Blatchford","character":"Jack Winthrop","img":null,"order":11},{"name":"Terry Kinney","character":"John Cameron","img":null,"order":12},{"name":"Tracey Ellis","character":"Alexandra Cameron","img":null,"order":13},{"name":"Justin M. Rice","character":"James Cameron","img":null,"order":14}],"movie:616":[{"name":"Tom Cruise","character":"Nathan Algren","img":null,"order":0},{"name":"Ken Watanabe","character":"Moritsugu Katsumoto","img":null,"order":1},{"name":"Timothy Spall","character":"Simon Graham","img":null,"order":2},{"name":"Tony Goldwyn","character":"Col. Benjamin Bagley","img":null,"order":3},{"name":"Hiroyuki Sanada","character":"Ujio","img":null,"order":4},{"name":"Koyuki","character":"Taka","img":null,"order":5},{"name":"Shin Koyamada","character":"Nobutada","img":null,"order":6},{"name":"Billy Connolly","character":"Zebulon Gant","img":null,"order":7},{"name":"Togo Igawa","character":"General Hasegawa","img":null,"order":8},{"name":"Shichinosuke Nakamura","character":"Emperor Meiji","img":null,"order":9},{"name":"Masato Harada","character":"Omura","img":null,"order":10},{"name":"William Atherton","character":"Winchester Rep","img":null,"order":11},{"name":"Chad Lindberg","character":"Winchester Rep Assistant","img":null,"order":12},{"name":"Scott Wilson","character":"Ambassador Swanbeck","img":null,"order":13},{"name":"Ray Godshall Sr.","character":"Convention Hall Attendee","img":null,"order":14}],"movie:11774":[{"name":"Emily Browning","character":"Violet Baudelaire","img":null,"order":0},{"name":"Liam Aiken","character":"Klaus Baudelaire","img":null,"order":1},{"name":"Kara Hoffman","character":"Sunny Baudelaire","img":null,"order":2},{"name":"Shelby Hoffman","character":"Sunny Baudelaire","img":null,"order":3},{"name":"Jim Carrey","character":"Count Olaf / Dr. Stephano / Captain Sham","img":null,"order":4},{"name":"Jude Law","character":"Lemony Snicket","img":null,"order":5},{"name":"Timothy Spall","character":"Mr. Poe","img":null,"order":6},{"name":"Meryl Streep","character":"Josephine Anwhistle","img":null,"order":7},{"name":"Billy Connolly","character":"Dr. Montgomery Montgomery","img":null,"order":8},{"name":"Catherine O'Hara","character":"Justice Strauss","img":null,"order":9},{"name":"Jennifer Coolidge","character":"White-Faced Women","img":null,"order":10},{"name":"Jane Adams","character":"White-Faced Women","img":null,"order":11},{"name":"Craig Ferguson","character":"Person of Indeterminate Gender","img":null,"order":12},{"name":"Luis Guzm\u00e1n","character":"Bald Man","img":null,"order":13},{"name":"Jamie Harris","character":"Hook-Handed Man","img":null,"order":14}],"movie:503919":[{"name":"Robert Pattinson","character":"Thomas Howard","img":null,"order":0},{"name":"Willem Dafoe","character":"Thomas Wake","img":null,"order":1},{"name":"Valeriia Karaman","character":"Mermaid","img":null,"order":2},{"name":"Logan Hawkes","character":"Ephraim Winslow","img":null,"order":3},{"name":"Kyla Nicolle","character":"Woman on the Rocks","img":null,"order":4},{"name":"Shaun Clarke","character":"Departing Wickie","img":null,"order":5},{"name":"Pierre Richard","character":"Departing Assistant Wickie","img":null,"order":6},{"name":"Preston Hudson","character":"Tender Mate","img":null,"order":7},{"name":"Jeff Cruts","character":"Tender Mate","img":null,"order":8}],"movie:603":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Gloria Foster","character":"Oracle","img":null,"order":4},{"name":"Joe Pantoliano","character":"Cypher","img":null,"order":5},{"name":"Marcus Chong","character":"Tank","img":null,"order":6},{"name":"Julian Arahanga","character":"Apoc","img":null,"order":7},{"name":"Matt Doran","character":"Mouse","img":null,"order":8},{"name":"Belinda McClory","character":"Switch","img":null,"order":9},{"name":"Anthony Ray Parker","character":"Dozer","img":null,"order":10},{"name":"Paul Goddard","character":"Agent Brown","img":null,"order":11},{"name":"Robert Taylor","character":"Agent Jones","img":null,"order":12},{"name":"David Aston","character":"Rhineheart","img":null,"order":13},{"name":"Marc Aden Gray","character":"Choi","img":null,"order":14}],"movie:604":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":4},{"name":"Gloria Foster","character":"The Oracle","img":null,"order":5},{"name":"Harold Perrineau","character":"Link","img":null,"order":6},{"name":"Monica Bellucci","character":"Persephone","img":null,"order":7},{"name":"Harry Lennix","character":"Commander Lock","img":null,"order":8},{"name":"Lambert Wilson","character":"The Merovingian","img":null,"order":9},{"name":"Randall Duk Kim","character":"Keymaker","img":null,"order":10},{"name":"Nona Gaye","character":"Zee","img":null,"order":11},{"name":"Anthony Zerbe","character":"Councillor Hamann","img":null,"order":12},{"name":"Daniel Bernhardt","character":"Agent Johnson","img":null,"order":13},{"name":"Helmut Bakaitis","character":"The Architect","img":null,"order":14}],"movie:624860":[{"name":"Keanu Reeves","character":"Neo / Thomas Anderson","img":null,"order":0},{"name":"Carrie-Anne Moss","character":"Trinity / Tiffany","img":null,"order":1},{"name":"Yahya Abdul-Mateen II","character":"Morpheus / Agent Smith","img":null,"order":2},{"name":"Jonathan Groff","character":"Smith","img":null,"order":3},{"name":"Jessica Henwick","character":"Bugs","img":null,"order":4},{"name":"Neil Patrick Harris","character":"The Analyst","img":null,"order":5},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":6},{"name":"Priyanka Chopra Jonas","character":"Sati","img":null,"order":7},{"name":"Christina Ricci","character":"Gwyn de Vere","img":null,"order":8},{"name":"Lambert Wilson","character":"The Merovingian","img":null,"order":9},{"name":"Andrew Lewis Caldwell","character":"Jude","img":null,"order":10},{"name":"Toby Onwumere","character":"Sequoia","img":null,"order":11},{"name":"Max Riemelt","character":"Sheperd","img":null,"order":12},{"name":"Joshua Grothe","character":"FunktI\u00f8n","img":null,"order":13},{"name":"Brian J. Smith","character":"Berg","img":null,"order":14}],"movie:605":[{"name":"Keanu Reeves","character":"Neo","img":null,"order":0},{"name":"Laurence Fishburne","character":"Morpheus","img":null,"order":1},{"name":"Carrie-Anne Moss","character":"Trinity","img":null,"order":2},{"name":"Hugo Weaving","character":"Agent Smith","img":null,"order":3},{"name":"Jada Pinkett Smith","character":"Niobe","img":null,"order":4},{"name":"Mary Alice","character":"The Oracle","img":null,"order":5},{"name":"Harold Perrineau","character":"Link","img":null,"order":6},{"name":"Collin Chou","character":"Seraph","img":null,"order":7},{"name":"Harry Lennix","character":"Commander Lock","img":null,"order":8},{"name":"Ian Bliss","character":"Bane","img":null,"order":9},{"name":"Lambert Wilson","character":"Merovingian","img":null,"order":10},{"name":"Bruce Spence","character":"Trainman","img":null,"order":11},{"name":"Monica Bellucci","character":"Persephone","img":null,"order":12},{"name":"Nona Gaye","character":"Zee","img":null,"order":13},{"name":"Anthony Zerbe","character":"Councillor Hamann","img":null,"order":14}],"movie:1236153":[{"name":"Chris Pratt","character":"Chris Raven","img":null,"order":0},{"name":"Rebecca Ferguson","character":"Judge Maddox","img":null,"order":1},{"name":"Kali Reis","character":"Jacqueline 'Jaq' Diallo","img":null,"order":2},{"name":"Kylie Rogers","character":"Britt Raven","img":null,"order":3},{"name":"Chris Sullivan","character":"Rob Nelson","img":null,"order":4},{"name":"Jeff Pierre","character":"Patrick Burke","img":null,"order":5},{"name":"Annabelle Wallis","character":"Nicole Raven","img":null,"order":6},{"name":"Rafi Gavron","character":"Holt Charles","img":null,"order":7},{"name":"Kenneth Choi","character":"Ray Vale","img":null,"order":8},{"name":"Jamie McBride","character":"Dan Vogel","img":null,"order":9},{"name":"Ross Gosla","character":"David Webb","img":null,"order":10},{"name":"Mark Daneri","character":"Jeff Martin","img":null,"order":11},{"name":"Haydn Dalton","character":"Bill Peterson","img":null,"order":12},{"name":"Michael C. Mahon","character":"Booking Sergeant","img":null,"order":13},{"name":"Noah Fearnley","character":"Tattoed Sleazebag","img":null,"order":14}],"movie:1242332":[{"name":"Bob Odenkirk","character":"Ulysses Richardson","img":null,"order":0},{"name":"Henry Winkler","character":"Mayor Kibner","img":null,"order":1},{"name":"Lena Headey","character":"Moira","img":null,"order":2},{"name":"Billy MacLellan","character":"Deputy Mike Nelson","img":null,"order":3},{"name":"Ryan Allen","character":"Deputy Blaine Anderson","img":null,"order":4},{"name":"Reena Jolly","character":"Lori","img":null,"order":5},{"name":"Brendan Fletcher","character":"Keith","img":null,"order":6},{"name":"Peter Shinkoda","character":"Joe","img":null,"order":7},{"name":"Jess McLeod","character":"Alex","img":null,"order":8},{"name":"Alex MacMillan","character":"Kent","img":null,"order":9},{"name":"Dan De Jaeger","character":"Ernie","img":null,"order":10},{"name":"John C. MacDonald","character":"Kenny","img":null,"order":11},{"name":"David Lawrence Brown","character":"Dr. Walter Vilie","img":null,"order":12},{"name":"Derek Barnes","character":"James","img":null,"order":13},{"name":"Megan MacArton","character":"Mary Beth","img":null,"order":14}],"movie:454227":[{"name":"Chris Pine","character":"Robert Bruce, Earl of Carrick","img":null,"order":0},{"name":"Aaron Taylor-Johnson","character":"James Douglas, Lord of Douglas","img":null,"order":1},{"name":"Florence Pugh","character":"Elizabeth Burgh","img":null,"order":2},{"name":"Billy Howle","character":"Edward, Prince of Wales","img":null,"order":3},{"name":"Sam Spruell","character":"Aymer de Valence, Earl of Pembroke","img":null,"order":4},{"name":"Tony Curran","character":"Angus Og Macdonald, Lord of Islay","img":null,"order":5},{"name":"Callan Mulvey","character":"John III Comyn, Lord of Badenoch","img":null,"order":6},{"name":"James Cosmo","character":"Robert Bruce Senior","img":null,"order":7},{"name":"Stephen Dillane","character":"King Edward I of England","img":null,"order":8},{"name":"Steven Cree","character":"Sir Christopher Seton","img":null,"order":9},{"name":"Alastair Mackenzie","character":"John Strathbogie, Earl of Atholl","img":null,"order":10},{"name":"Chris Fulton","character":"Euan Bruce","img":null,"order":11},{"name":"Lorne MacFadyen","character":"Neil Bruce","img":null,"order":12},{"name":"Jack Greenlees","character":"Alexander Bruce","img":null,"order":13},{"name":"Rebecca Robin","character":"Queen Margaret of England","img":null,"order":14}],"movie:4547":[{"name":"Jodie Foster","character":"Meg Altman","img":null,"order":0},{"name":"Kristen Stewart","character":"Sarah Altman","img":null,"order":1},{"name":"Forest Whitaker","character":"Burnham","img":null,"order":2},{"name":"Dwight Yoakam","character":"Raoul","img":null,"order":3},{"name":"Jared Leto","character":"Junior","img":null,"order":4},{"name":"Patrick Bauchau","character":"Stephen Altman","img":null,"order":5},{"name":"Ann Magnuson","character":"Lydia Lynch","img":null,"order":6},{"name":"Ian Buchanan","character":"Evan Kurlander","img":null,"order":7},{"name":"Andrew Kevin Walker","character":"Sleepy Neighbor","img":null,"order":8},{"name":"Paul Schulze","character":"Officer Keeney","img":null,"order":9},{"name":"Mel Rodriguez","character":"Officer Morales","img":null,"order":10},{"name":"Richard Conant","character":"SWAT Cop","img":null,"order":11},{"name":"Paul Simon","character":"SWAT Cop","img":null,"order":12},{"name":"Victor Thrash","character":"SWAT Cop","img":null,"order":13},{"name":"Ken Turner","character":"SWAT Cop","img":null,"order":14}],"movie:2024":[{"name":"Mel Gibson","character":"Benjamin Martin","img":null,"order":0},{"name":"Heath Ledger","character":"Gabriel Martin","img":null,"order":1},{"name":"Joely Richardson","character":"Charlotte Selton","img":null,"order":2},{"name":"Jason Isaacs","character":"Col. William Tavington","img":null,"order":3},{"name":"Chris Cooper","character":"Col. Harry Burwell","img":null,"order":4},{"name":"Tch\u00e9ky Karyo","character":"Jean Villeneuve","img":null,"order":5},{"name":"Ren\u00e9 Auberjonois","character":"Reverend Oliver","img":null,"order":6},{"name":"Lisa Brenner","character":"Anne Howard","img":null,"order":7},{"name":"Tom Wilkinson","character":"Gen. Cornwallis","img":null,"order":8},{"name":"Donal Logue","character":"Dan Scott","img":null,"order":9},{"name":"Leon Rippy","character":"John Billings","img":null,"order":10},{"name":"Adam Baldwin","character":"Capt. Wilkins","img":"lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","order":11},{"name":"Jay Arlen Jones","character":"Occam","img":null,"order":12},{"name":"Joey D. Vieira","character":"Peter Howard","img":null,"order":13},{"name":"Gregory Smith","character":"Thomas Martin","img":null,"order":14}],"movie:875828":[{"name":"Cillian Murphy","character":"Tommy Shelby","img":null,"order":0},{"name":"Barry Keoghan","character":"Duke Shelby","img":null,"order":1},{"name":"Rebecca Ferguson","character":"Kaulo / Zelda","img":null,"order":2},{"name":"Tim Roth","character":"John Beckett","img":null,"order":3},{"name":"Stephen Graham","character":"Hayden Stagg","img":null,"order":4},{"name":"Sophie Rundle","character":"Ada Shelby","img":null,"order":5},{"name":"Ned Dennehy","character":"Charlie Strong","img":null,"order":6},{"name":"Packy Lee","character":"Johnny Dogs","img":null,"order":7},{"name":"Ian Peck","character":"Curly","img":null,"order":8},{"name":"Jay Lycurgo","character":"Elijah","img":null,"order":9},{"name":"Kasper Hilton-Hille","character":"Karl Thorne","img":null,"order":10},{"name":"Ruby Ashbourne Serkis","character":"Agnes Shelby","img":null,"order":11},{"name":"Bonnie Stott","character":"Ruby","img":null,"order":12},{"name":"Thomas Arnold","character":"Virgil","img":null,"order":13},{"name":"Sam Baker Jones","character":"Jake","img":null,"order":14}],"movie:12429":[{"name":"Yuria Kozuki","character":"Ponyo (voice)","img":null,"order":0},{"name":"Hiroki Doi","character":"Sosuke (voice)","img":null,"order":1},{"name":"George Tokoro","character":"Fujimoto (voice)","img":null,"order":2},{"name":"Tomoko Yamaguchi","character":"Risa (voice)","img":null,"order":3},{"name":"Yuki Amami","character":"Gran Mamare (voice)","img":null,"order":4},{"name":"Kazushige Nagashima","character":"Koichi (voice)","img":null,"order":5},{"name":"Akiko Yano","character":"Ponyo's Sister (voice)","img":null,"order":6},{"name":"Shin'ichi Hatori","character":"Anchorman (voice)","img":null,"order":7},{"name":"Tokie Hidari","character":"Kayo (voice)","img":null,"order":8},{"name":"Rumi Hiiragi","character":"Young Mother (voice)","img":null,"order":9},{"name":"Tomoko Naraoka","character":"Yoshie (voice)","img":null,"order":10},{"name":"Kazuko Yoshiyuki","character":"Toki (voice)","img":null,"order":11},{"name":"Eimi Hiraoka","character":"Kumiko (voice)","img":null,"order":12},{"name":"Nozomi \u014chashi","character":"Karen (voice)","img":null,"order":13},{"name":"Akiko Takeguchi","character":"Noriko (voice)","img":null,"order":14}],"movie:11621":[{"name":"Sh\u016bichir\u014d Moriyama","character":"Porco Rosso (voice)","img":null,"order":0},{"name":"Tokiko Kato","character":"Madame Gina (voice)","img":null,"order":1},{"name":"Bunshi Katsura VI","character":"Grandpa Piccolo (voice)","img":null,"order":2},{"name":"Tsunehiko Kamij\u00f4","character":"Mamma Aiuto Boss (voice)","img":null,"order":3},{"name":"Akemi Okamura","character":"Fio Piccolo (voice)","img":null,"order":4},{"name":"Akio Otsuka","character":"Donald Curtis (voice)","img":null,"order":5},{"name":"Hiroko Seki","character":"Granny (voice)","img":null,"order":6},{"name":"Masayuki Inagaki","character":"Major Ferrarin (voice)","img":null,"order":7},{"name":"Reiz\u014d Nomoto","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":8},{"name":"Osamu Saka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":9},{"name":"Yu Shimaka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":10},{"name":"Yuzuru Fujimoto","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":11},{"name":"Tatsuyuki Jinnai","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":12},{"name":"Kazunori Arai","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":13},{"name":"Nobuo Tanaka","character":"Mamma Aiuto Gang Member (voice)","img":null,"order":14}],"movie:128":[{"name":"Yoji Matsuda","character":"Ashitaka (voice)","img":null,"order":0},{"name":"Yuriko Ishida","character":"San / Kaya (voice)","img":null,"order":1},{"name":"Yuko Tanaka","character":"Eboshi Gozen (voice)","img":null,"order":2},{"name":"Kaoru Kobayashi","character":"Jikobo (voice)","img":null,"order":3},{"name":"Masahiko Nishimura","character":"Kouroku (voice)","img":null,"order":4},{"name":"Tsunehiko Kamij\u00f4","character":"Gonza (voice)","img":null,"order":5},{"name":"Akihiro Miwa","character":"Mrs. Moro (voice)","img":null,"order":6},{"name":"Mitsuko Mori","character":"Mrs. Hii (voice)","img":null,"order":7},{"name":"Hisaya Morishige","character":"Chief of Affairs (voice)","img":null,"order":8},{"name":"Sumi Shimamoto","character":"Toki (voice)","img":null,"order":9},{"name":"Tetsu Watanabe","character":"Wild Dog (voice)","img":null,"order":10},{"name":"Makoto Sat\u014d","character":"Tatari God (voice)","img":null,"order":11},{"name":"Akira Nagoya","character":"Cowherd (voice)","img":null,"order":12},{"name":"Kei Iinuma","character":"Chief of the Sick (voice)","img":null,"order":13},{"name":"Tsuzuki Kayako","character":"Kiyo (voice)","img":null,"order":14}],"movie:146233":[{"name":"Hugh Jackman","character":"Keller Dover","img":null,"order":0},{"name":"Jake Gyllenhaal","character":"Detective Loki","img":null,"order":1},{"name":"Viola Davis","character":"Nancy Birch","img":null,"order":2},{"name":"Maria Bello","character":"Grace Dover","img":null,"order":3},{"name":"Terrence Howard","character":"Franklin Birch","img":null,"order":4},{"name":"Melissa Leo","character":"Holly Jones","img":null,"order":5},{"name":"Paul Dano","character":"Alex Jones","img":null,"order":6},{"name":"Dylan Minnette","character":"Ralph Dover","img":null,"order":7},{"name":"Zo\u00eb Soul","character":"Eliza Birch","img":null,"order":8},{"name":"Erin Gerasimovich","character":"Anna Dover","img":null,"order":9},{"name":"Kyla-Drew","character":"Joy Birch","img":null,"order":10},{"name":"Wayne Duvall","character":"Captain Richard O'Malley","img":null,"order":11},{"name":"Len Cariou","character":"Father Patrick Dunn","img":null,"order":12},{"name":"David Dastmalchian","character":"Bob Taylor","img":null,"order":13},{"name":"Brad James","character":"Officer Carter","img":null,"order":14}],"movie:6439":[{"name":"Frankie Muniz","character":"Stripes (voice)","img":null,"order":0},{"name":"Hayden Panettiere","character":"Channing Walsh","img":null,"order":1},{"name":"Bruce Greenwood","character":"Nolan Walsh","img":null,"order":2},{"name":"Mandy Moore","character":"Sandy (voice)","img":null,"order":3},{"name":"Wendie Malick","character":"Clara Dalrymple","img":null,"order":4},{"name":"M. Emmet Walsh","character":"Woodzie","img":null,"order":5},{"name":"Snoop Dogg","character":"Lightning (voice)","img":"rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","order":6},{"name":"Michael Clarke Duncan","character":"Clydesdale (voice)","img":null,"order":7},{"name":"Jeff Foxworthy","character":"Reggie (voice)","img":null,"order":8},{"name":"Whoopi Goldberg","character":"Franny (voice)","img":null,"order":9},{"name":"Steve Harvey","character":"Buzz (voice)","img":null,"order":10},{"name":"David Spade","character":"Scuzz (voice)","img":null,"order":11},{"name":"Dustin Hoffman","character":"Tucker (voice)","img":null,"order":12},{"name":"Joshua Jackson","character":"Trenton's Pride (voice)","img":null,"order":13},{"name":"Joe Pantoliano","character":"Goose (voice)","img":null,"order":14}],"movie:641":[{"name":"Ellen Burstyn","character":"Sara Goldfarb","img":null,"order":0},{"name":"Jared Leto","character":"Harry Goldfarb","img":null,"order":1},{"name":"Jennifer Connelly","character":"Marion Silver","img":null,"order":2},{"name":"Marlon Wayans","character":"Tyrone C. Love","img":null,"order":3},{"name":"Christopher McDonald","character":"Tappy Tibbons","img":null,"order":4},{"name":"Louise Lasser","character":"Ada","img":null,"order":5},{"name":"Marcia Jean Kurtz","character":"Rae","img":null,"order":6},{"name":"Janet Sarno","character":"Mrs. Pearlman","img":null,"order":7},{"name":"Suzanne Shepherd","character":"Mrs. Scarlini","img":null,"order":8},{"name":"Joanne Gordon","character":"Mrs. Ovadia","img":null,"order":9},{"name":"Charlotte Aronofsky","character":"Mrs. Miles","img":null,"order":10},{"name":"Mark Margolis","character":"Mr. Rabinowitz","img":null,"order":11},{"name":"Michael Kaycheck","character":"Donut Cop","img":null,"order":12},{"name":"Jack A. O'Connell","character":"Corn Dog Stand Boss","img":null,"order":13},{"name":"Chas Mastin","character":"Lyle Russel","img":null,"order":14}],"movie:330459":[{"name":"Felicity Jones","character":"Jyn Erso","img":null,"order":0},{"name":"Diego Luna","character":"Cassian Andor","img":"qwbjcyDN37OHiln4a6P4LP31o7F.jpg","order":1},{"name":"Alan Tudyk","character":"K-2SO","img":"jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","order":2},{"name":"Donnie Yen","character":"Chirrut Imwe","img":null,"order":3},{"name":"Jiang Wen","character":"Baze Malbus","img":null,"order":4},{"name":"Ben Mendelsohn","character":"Orson Krennic","img":null,"order":5},{"name":"Guy Henry","character":"Governor Tarkin","img":null,"order":6},{"name":"Forest Whitaker","character":"Saw Gerrera","img":null,"order":7},{"name":"Riz Ahmed","character":"Bodhi Rook","img":null,"order":8},{"name":"Mads Mikkelsen","character":"Galen Erso","img":null,"order":9},{"name":"Jimmy Smits","character":"Bail Organa","img":null,"order":10},{"name":"Alistair Petrie","character":"General Draven","img":null,"order":11},{"name":"Genevieve O'Reilly","character":"Mon Mothma","img":"4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","order":12},{"name":"Ben Daniels","character":"General Merrick","img":"phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","order":13},{"name":"Paul Kasey","character":"Admiral Raddus","img":null,"order":14}],"movie:13156":[{"name":"Michael Caine","character":"Garth","img":null,"order":0},{"name":"Robert Duvall","character":"Hub","img":null,"order":1},{"name":"Haley Joel Osment","character":"Walter","img":null,"order":2},{"name":"Josh Lucas","character":"Adult Walter","img":null,"order":3},{"name":"Kyra Sedgwick","character":"Mae","img":null,"order":4},{"name":"Christian Kane","character":"Young Hub","img":null,"order":5},{"name":"Nicky Katt","character":"Stan","img":null,"order":6},{"name":"Eric Balfour","character":"Sheik's Grandson","img":null,"order":7},{"name":"Kevin Haberer","character":"Young Garth","img":null,"order":8},{"name":"Emmanuelle Vaugier","character":"Jasmine","img":null,"order":9},{"name":"Mitchel Musso","character":"Boy","img":null,"order":10},{"name":"Marc Musso","character":"Boy","img":null,"order":11},{"name":"Michael O'Neill","character":"Ralph","img":null,"order":12},{"name":"Deirdre O'Connell","character":"Helen","img":null,"order":13},{"name":"Adam Ozturk","character":"The Sheik","img":null,"order":14}],"movie:412117":[{"name":"Patton Oswalt","character":"Max (voice)","img":null,"order":0},{"name":"Kevin Hart","character":"Snowball (voice)","img":null,"order":1},{"name":"Eric Stonestreet","character":"Duke (voice)","img":null,"order":2},{"name":"Jenny Slate","character":"Gidget (voice)","img":null,"order":3},{"name":"Tiffany Haddish","character":"Daisy (voice)","img":null,"order":4},{"name":"Lake Bell","character":"Chloe (voice)","img":null,"order":5},{"name":"Nick Kroll","character":"Sergei (voice)","img":null,"order":6},{"name":"Dana Carvey","character":"Pops (voice)","img":null,"order":7},{"name":"Ellie Kemper","character":"Katie (voice)","img":null,"order":8},{"name":"Harrison Ford","character":"Rooster (voice)","img":null,"order":9},{"name":"Chris Renaud","character":"Norman / Additional Voices (voice)","img":null,"order":10},{"name":"Bobby Moynihan","character":"Mel (voice)","img":null,"order":11},{"name":"Hannibal Buress","character":"Buddy (voice)","img":null,"order":12},{"name":"Pete Holmes","character":"Chuck (voice)","img":null,"order":13},{"name":"Henry Lynch","character":"Liam (voice)","img":null,"order":14}],"movie:1072876":[{"name":"Jake Johnson","character":"Tommy","img":null,"order":0},{"name":"Anna Kendrick","character":"Maddy","img":null,"order":1},{"name":"Natalie Morales","character":"Theresa","img":null,"order":2},{"name":"Andy Samberg","character":"Andy Samberg","img":null,"order":3},{"name":"Mary Holland","character":"Amy","img":null,"order":4},{"name":"Emily Hampshire","character":"Mary","img":null,"order":5},{"name":"Nancy Lenehan","character":"Laurie","img":null,"order":6},{"name":"Daryl J. Johnson","character":"Malcolm","img":null,"order":7},{"name":"Miriam Flynn","character":"Becky Ann","img":null,"order":8},{"name":"Boban Marjanovi\u0107","character":"7 Foot Man","img":null,"order":9},{"name":"Biff Wiff","character":"James / Walter","img":null,"order":10},{"name":"Eduardo Franco","character":"P.A. Ninja","img":null,"order":11},{"name":"Jeff Kober","character":"Cowboy","img":null,"order":12},{"name":"Christopher Lloyd","character":"Dennis Walcott","img":null,"order":13},{"name":"Theo Wilson","character":"Sinbad Guy","img":null,"order":14}],"movie:9040":[{"name":"Al Pacino","character":"Officer Frank Serpico","img":null,"order":0},{"name":"John Randolph","character":"Sidney Green","img":null,"order":1},{"name":"Jack Kehoe","character":"Tom Keough","img":null,"order":2},{"name":"Biff McGuire","character":"Captain McClain","img":null,"order":3},{"name":"Barbara Eda-Young","character":"Laurie","img":null,"order":4},{"name":"Cornelia Sharpe","character":"Leslie Lane","img":null,"order":5},{"name":"Tony Roberts","character":"Bob Blair","img":null,"order":6},{"name":"John Medici","character":"Pasquale","img":null,"order":7},{"name":"Allan Rich","character":"D. A. Tauber","img":null,"order":8},{"name":"Norman Ornellas","character":"Rubello","img":null,"order":9},{"name":"Edward Grover","character":"Lombardo","img":null,"order":10},{"name":"Albert Henderson","character":"Peluce","img":null,"order":11},{"name":"Hank Garrett","character":"Malone","img":null,"order":12},{"name":"Damien Leake","character":"Joey","img":null,"order":13},{"name":"Joseph Bova","character":"Potts","img":null,"order":14}],"movie:479":[{"name":"Samuel L. Jackson","character":"John Shaft","img":null,"order":0},{"name":"Vanessa Williams","character":"Carmen Vasquez","img":null,"order":1},{"name":"Jeffrey Wright","character":"Peoples Hernandez","img":null,"order":2},{"name":"Christian Bale","character":"Walter Wade, Jr.","img":null,"order":3},{"name":"Busta Rhymes","character":"Rasaan","img":null,"order":4},{"name":"Dan Hedaya","character":"Jack Roselli","img":null,"order":5},{"name":"Toni Collette","character":"Diane Palmieri","img":null,"order":6},{"name":"Richard Roundtree","character":"\"Uncle\" John Shaft","img":null,"order":7},{"name":"Ruben Santiago-Hudson","character":"Jimmy Groves","img":null,"order":8},{"name":"Josef Sommer","character":"Curt Flemming","img":null,"order":9},{"name":"Lynne Thigpen","character":"Carla Howard","img":null,"order":10},{"name":"Philip Bosco","character":"Walter Wade Senior","img":null,"order":11},{"name":"Pat Hingle","character":"Judge Dennis Bradford","img":null,"order":12},{"name":"Lee Tergesen","character":"Luger","img":null,"order":13},{"name":"Daniel von Bargen","character":"Lt Kearney","img":null,"order":14}],"movie:378064":[{"name":"Miyu Irino","character":"Shouya Ishida (voice)","img":null,"order":0},{"name":"Saori Hayami","character":"Shouko Nishimiya (voice)","img":null,"order":1},{"name":"Aoi Yuki","character":"Yuzuru Nishimiya (voice)","img":null,"order":2},{"name":"Kensho Ono","character":"Tomohiro Nagatsuka (voice)","img":null,"order":3},{"name":"Yuki Kaneko","character":"Naoka Ueno (voice)","img":null,"order":4},{"name":"Yui Ishikawa","character":"Miyoko Sahara (voice)","img":null,"order":5},{"name":"Megumi Han","character":"Miki Kawai (voice)","img":null,"order":6},{"name":"Toshiyuki Toyonaga","character":"Satoshi Mashiba (voice)","img":null,"order":7},{"name":"Mayu Matsuoka","character":"Shouya Ishida as an Elementary School Boy (voice)","img":null,"order":8},{"name":"Sachiko Kojima","character":"Kazuki Shimada as an Elementary School Boy (voice)","img":null,"order":9},{"name":"Hana Takeda","character":"Keisuke Hirose as an Elementary School Boy (voice)","img":null,"order":10},{"name":"Fuminori Komatsu","character":"Takeuchi-sensei (voice)","img":null,"order":11},{"name":"Ikuko Tani","character":"Ito Nishimiya (voice)","img":null,"order":12},{"name":"Erena Kamata","character":"Maria Ishida (voice)","img":null,"order":13},{"name":"Ayano Hamaguchi","character":"Shouya's Sister (voice)","img":null,"order":14}],"movie:14411":[{"name":"Brad Pitt","character":"Sinbad (voice)","img":null,"order":0},{"name":"Catherine Zeta-Jones","character":"Marina (voice)","img":null,"order":1},{"name":"Michelle Pfeiffer","character":"Eris (voice)","img":null,"order":2},{"name":"Joseph Fiennes","character":"Proteus (voice)","img":null,"order":3},{"name":"Dennis Haysbert","character":"Kale (voice)","img":null,"order":4},{"name":"Timothy West","character":"Dymas (voice)","img":null,"order":5},{"name":"Adriano Giannini","character":"Rat (voice)","img":null,"order":6},{"name":"Raman Hui Shing-Ngai","character":"Jin (voice)","img":null,"order":7},{"name":"Chung Chan","character":"Li (voice)","img":null,"order":8},{"name":"Jim Cummings","character":"Luca / Additional Voices (voice)","img":null,"order":9},{"name":"Conrad Vernon","character":"Jed (voice)","img":null,"order":10},{"name":"Andrew Birch","character":"Grum / Chum (voice)","img":null,"order":11},{"name":"Chris Miller","character":"Tower Guard (voice)","img":null,"order":12},{"name":"Frank Welker","character":"Spike (voice) (uncredited)","img":null,"order":13}],"movie:288036":[{"name":"Jason Sudeikis","character":"Jake Harper","img":null,"order":0},{"name":"Alison Brie","character":"Lainey Dalton","img":null,"order":1},{"name":"Adam Scott","character":"Matthew Sobvechik","img":null,"order":2},{"name":"Jason Mantzoukas","character":"Xander","img":null,"order":3},{"name":"Natasha Lyonne","character":"Kara","img":null,"order":4},{"name":"Adam Brody","character":"Sam","img":null,"order":5},{"name":"Amanda Peet","character":"Paula","img":null,"order":6},{"name":"Katherine Waterston","character":"Emma","img":null,"order":7},{"name":"Marc Blucas","character":"Chris Smith","img":null,"order":8},{"name":"Margarita Levieva","character":"Hannah","img":null,"order":9},{"name":"Andrea Savage","character":"Naomi","img":null,"order":10},{"name":"Daniella Pineda","character":"Danica","img":null,"order":11},{"name":"Remy Nozik","character":"George","img":null,"order":12},{"name":"Megan Guinan","character":"Alice","img":null,"order":13},{"name":"Anna Margaret Hollyman","character":"Renee","img":null,"order":14}],"movie:110415":[{"name":"Chris Evans","character":"Curtis","img":null,"order":0},{"name":"Song Kang-ho","character":"Namgoong Minsu","img":null,"order":1},{"name":"Ed Harris","character":"Wilford","img":null,"order":2},{"name":"John Hurt","character":"Gilliam","img":null,"order":3},{"name":"Tilda Swinton","character":"Mason","img":null,"order":4},{"name":"Jamie Bell","character":"Edgar","img":null,"order":5},{"name":"Octavia Spencer","character":"Tanya","img":null,"order":6},{"name":"Ewen Bremner","character":"Andrew","img":null,"order":7},{"name":"Ko Asung","character":"Yona","img":null,"order":8},{"name":"Alison Pill","character":"Teacher","img":null,"order":9},{"name":"Luke Pasqualino","character":"Grey","img":null,"order":10},{"name":"Vlad Ivanov","character":"Franco The Elder","img":null,"order":11},{"name":"Adnan Haskovi\u0107","character":"Franco The Younger","img":null,"order":12},{"name":"Emma Levie","character":"Claude","img":null,"order":13},{"name":"Steve Park","character":"Fuyu","img":null,"order":14}],"movie:969681":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Sadie Sink","character":"Jean Grey","img":null,"order":1},{"name":"Tramell Tillman","character":"William 'Bill' Metzger","img":null,"order":2},{"name":"Zendaya","character":"MJ","img":null,"order":3},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":4},{"name":"Jon Bernthal","character":"Frank Castle / Punisher","img":null,"order":5},{"name":"Mark Ruffalo","character":"Bruce Banner / Hulk","img":null,"order":6},{"name":"Florence Pugh","character":"Yelena Belova / Black Widow","img":null,"order":7},{"name":"Liza Col\u00f3n-Zayas","character":"Detective Jean DeWolff","img":null,"order":8},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":9},{"name":"Naomi Watts","character":"E.V. (voice)","img":null,"order":10},{"name":"Michael Mando","character":"Mac Gargan / Scorpion","img":null,"order":11},{"name":"Keith David","character":"Narrator of Spider Video (voice)","img":null,"order":12},{"name":"Marvin Jones III","character":"Tombstone","img":null,"order":13},{"name":"Olivia Booth-Ford","character":"Sara","img":null,"order":14}],"movie:429617":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Jake Gyllenhaal","character":"Quentin Beck / Mysterio","img":null,"order":1},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":2},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Zendaya","character":"MJ","img":null,"order":5},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":6},{"name":"Tony Revolori","character":"Flash Thompson","img":null,"order":7},{"name":"Angourie Rice","character":"Betty Brant","img":null,"order":8},{"name":"Remy Hii","character":"Brad Davis","img":null,"order":9},{"name":"Martin Starr","character":"Mr. Harrington","img":null,"order":10},{"name":"JB Smoove","character":"Mr. Dell","img":null,"order":11},{"name":"Jorge Lendeborg Jr.","character":"Jason Ionello","img":null,"order":12},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":13},{"name":"Numan Acar","character":"Dimitri","img":null,"order":14}],"movie:315635":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Michael Keaton","character":"Adrian Toomes / Vulture","img":null,"order":1},{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":2},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Gwyneth Paltrow","character":"Pepper Potts","img":null,"order":5},{"name":"Zendaya","character":"Michelle","img":null,"order":6},{"name":"Donald Glover","character":"Aaron Davis","img":null,"order":7},{"name":"Jacob Batalon","character":"Ned","img":null,"order":8},{"name":"Laura Harrier","character":"Liz","img":null,"order":9},{"name":"Tony Revolori","character":"Flash","img":null,"order":10},{"name":"Bokeem Woodbine","character":"Herman Schultz / Shocker #2","img":null,"order":11},{"name":"Tyne Daly","character":"Anne Marie Hoag","img":null,"order":12},{"name":"Abraham Attah","character":"Abraham","img":null,"order":13},{"name":"Hannibal Buress","character":"Coach Wilson","img":null,"order":14}],"movie:634649":[{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":0},{"name":"Zendaya","character":"MJ","img":null,"order":1},{"name":"Benedict Cumberbatch","character":"Doctor Strange","img":null,"order":2},{"name":"Jacob Batalon","character":"Ned Leeds","img":null,"order":3},{"name":"Jon Favreau","character":"Happy Hogan","img":null,"order":4},{"name":"Jamie Foxx","character":"Max Dillon / Electro","img":null,"order":5},{"name":"Willem Dafoe","character":"Norman Osborn / Green Goblin","img":null,"order":6},{"name":"Alfred Molina","character":"Dr. Otto Octavius / Doc Ock","img":null,"order":7},{"name":"Benedict Wong","character":"Wong","img":null,"order":8},{"name":"Tony Revolori","character":"Flash Thompson","img":null,"order":9},{"name":"Marisa Tomei","character":"May Parker","img":null,"order":10},{"name":"Andrew Garfield","character":"Peter Parker / Spider-Man","img":null,"order":11},{"name":"Tobey Maguire","character":"Peter Parker / Spider-Man","img":null,"order":12},{"name":"Angourie Rice","character":"Betty Brant","img":null,"order":13},{"name":"Arian Moayed","character":"Agent Cleary","img":null,"order":14}],"movie:129":[{"name":"Rumi Hiiragi","character":"Chihiro (voice)","img":null,"order":0},{"name":"Miyu Irino","character":"Haku (voice)","img":null,"order":1},{"name":"Mari Natsuki","character":"Yubaba / Zeniba (voice)","img":null,"order":2},{"name":"Takashi Naito","character":"Father (voice)","img":null,"order":3},{"name":"Yasuko Sawaguchi","character":"Mother (voice)","img":null,"order":4},{"name":"Tatsuya Gashuin","character":"Aogaeru (voice)","img":null,"order":5},{"name":"Ryunosuke Kamiki","character":"Boh (voice)","img":null,"order":6},{"name":"Yumi Tamai","character":"Rin (voice)","img":null,"order":7},{"name":"Yo Oizumi","character":"Bandai Frog (voice)","img":null,"order":8},{"name":"Koba Hayashi","character":"River Spirit (voice)","img":null,"order":9},{"name":"Tsunehiko Kamij\u00f4","character":"Chichiyaku (voice)","img":null,"order":10},{"name":"Takehiko Ono","character":"Aniyaku (voice)","img":null,"order":11},{"name":"Bunta Sugawara","character":"Kamaji (voice)","img":null,"order":12},{"name":"Shigeru Wakita","character":"(voice)","img":null,"order":13},{"name":"Shirou Saitou","character":"(voice)","img":null,"order":14}],"movie:933260":[{"name":"Demi Moore","character":"Elisabeth","img":null,"order":0},{"name":"Margaret Qualley","character":"Sue","img":null,"order":1},{"name":"Dennis Quaid","character":"Harvey","img":null,"order":2},{"name":"Edward Hamilton-Clark","character":"Fred","img":null,"order":3},{"name":"Gore Abrams","character":"Oliver","img":null,"order":4},{"name":"Oscar Lesage","character":"Troy","img":null,"order":5},{"name":"Christian Erickson","character":"Man at Diner","img":null,"order":6},{"name":"Robin Greer","character":"Male Nurse","img":null,"order":7},{"name":"Tom Morton","character":"Doctor","img":null,"order":8},{"name":"Hugo Diego Garcia","character":"Diego - Boyfriend","img":null,"order":9},{"name":"Daniel Knight","character":"Casting Director","img":null,"order":10},{"name":"Jonathon Carley","character":"Assistant Casting Director","img":null,"order":11},{"name":"Jiselle Henderkott","character":"Girl Auditioning / New Year's Eve Show Dancer","img":null,"order":12},{"name":"Akil Wingate","character":"TV Host","img":null,"order":13},{"name":"Vincent Colombe","character":"Man in Suit #1 - Harvey's Office","img":null,"order":14}],"movie:149871":[{"name":"Aki Asakura","character":"The Princess Kaguya (voice)","img":null,"order":0},{"name":"Takeo Chii","character":"Sanuki no Miyatsuko (voice)","img":null,"order":1},{"name":"Nobuko Miyamoto","character":"\u014cna / Narrator (voice)","img":null,"order":2},{"name":"Kengo Kora","character":"Sutemaru (voice)","img":null,"order":3},{"name":"Atsuko Takahata","character":"Lady Sagami (voice)","img":null,"order":4},{"name":"Tomoko Tabata","character":"Menowarawa (voice)","img":null,"order":5},{"name":"Shinosuke Tatekawa","character":"Lord Inbe no Akita (voice)","img":null,"order":6},{"name":"Takaya Kamikawa","character":"Prince Ishitsukuri (voice)","img":null,"order":7},{"name":"Hikaru Ij\u016bin","character":"Minister of the Right Abe (voice)","img":null,"order":8},{"name":"Ryudo Uzaki","character":"Grand Counselor Otomo (voice)","img":null,"order":9},{"name":"Shichinosuke Nakamura","character":"The Emperor (voice)","img":null,"order":10},{"name":"Isao Hashizume","character":"Prince Kuramochi (voice)","img":null,"order":11},{"name":"Tatsuya Nakadai","character":"Charcoal-maker Old Man (voice)","img":null,"order":12},{"name":"Yukiji Asaoka","character":"Kita no kata (voice)","img":null,"order":13},{"name":"Tamaki Kojo","character":"Middle Counselor Isonokami (voice)","img":null,"order":14}],"movie:103":[{"name":"Robert De Niro","character":"Travis Bickle","img":null,"order":0},{"name":"Jodie Foster","character":"Iris","img":null,"order":1},{"name":"Cybill Shepherd","character":"Betsy","img":null,"order":2},{"name":"Harvey Keitel","character":"Sport","img":null,"order":3},{"name":"Peter Boyle","character":"Wizard","img":null,"order":4},{"name":"Leonard Harris","character":"Charles Palantine","img":null,"order":5},{"name":"Albert Brooks","character":"Tom","img":null,"order":6},{"name":"Diahnne Abbott","character":"Concession Girl","img":null,"order":7},{"name":"Frank Adu","character":"Angry Black Man","img":null,"order":8},{"name":"Victor Argo","character":"Melio","img":null,"order":9},{"name":"Gino Ardito","character":"Policeman at Rally","img":null,"order":10},{"name":"Garth Avery","character":"Iris' Friend","img":null,"order":11},{"name":"Harry Cohn","character":"Cabbie in Bellmore","img":null,"order":12},{"name":"Cooper Cunningham","character":"Hooker in Cab","img":null,"order":13},{"name":"Brenda Dickson","character":"Soap Opera Woman","img":null,"order":14}],"movie:497828":[{"name":"Harris Dickinson","character":"Carl","img":null,"order":0},{"name":"Charlbi Dean","character":"Yaya","img":null,"order":1},{"name":"Dolly de Leon","character":"Abigail","img":null,"order":2},{"name":"Woody Harrelson","character":"The Captain","img":null,"order":3},{"name":"Zlatko Buri\u0107","character":"Dimitry","img":null,"order":4},{"name":"Vicki Berlin","character":"Paula","img":null,"order":5},{"name":"Henrik Dorsin","character":"Jarmo","img":null,"order":6},{"name":"Iris Berben","character":"Therese","img":null,"order":7},{"name":"Jean-Christophe Folly","character":"Nelson","img":null,"order":8},{"name":"Amanda Walker","character":"Clementine","img":null,"order":9},{"name":"Oliver Ford Davies","character":"Winston","img":null,"order":10},{"name":"Sunnyi Melles","character":"Vera","img":null,"order":11},{"name":"Thobias Thorwid","character":"Lewis","img":null,"order":12},{"name":"Jiannis Moustos","character":"Driver","img":null,"order":13},{"name":"Timoleon Gketsos","character":"Handsome Crewman","img":null,"order":14}],"movie:868":[{"name":"Presley Chweneyagae","character":"Tsotsi","img":null,"order":0},{"name":"Jerry Mofokeng","character":"Morris","img":null,"order":1},{"name":"Terry Pheto","character":"Miriam","img":null,"order":2},{"name":"Zenzo Ngqobe","character":"Butcher","img":null,"order":3},{"name":"Zola","character":"Fela","img":null,"order":4},{"name":"Rapulana Seiphemo","character":"John Dube","img":null,"order":5},{"name":"Nambitha Mpumlwana","character":"Pumla Dube","img":null,"order":6},{"name":"Percy Matsemela","character":"Sergeant Zuma","img":null,"order":7},{"name":"Thembi Nyandeni","character":"Soekie","img":null,"order":8},{"name":"Owen Sejake","character":"Gumboot Dlamini","img":null,"order":9},{"name":"Israel Makoe","character":"Tsotsi's father","img":null,"order":10},{"name":"Sindi Khambule","character":"Tsotsi's Mother","img":null,"order":11},{"name":"Benny Moshe","character":"Young Tsotsi","img":null,"order":12},{"name":"Mothusi Magano","character":"Boston","img":null,"order":13},{"name":"Kenneth Nkosi","character":"Aap","img":null,"order":14}],"movie:1517772":[{"name":"Lauryn Licari","character":"Self","img":null,"order":0},{"name":"Owen McKenny","character":"Self","img":null,"order":1},{"name":"Sophie Weber","character":"Self","img":null,"order":2},{"name":"Macy Johnston","character":"Self","img":null,"order":3},{"name":"Shawn Licari","character":"Self","img":null,"order":4},{"name":"Kendra Licari","character":"Self","img":null,"order":5},{"name":"Dave McKenny","character":"Self","img":null,"order":6},{"name":"Jill McKenny","character":"Self","img":null,"order":7},{"name":"Bill Chillman","character":"Self - The Superintendent","img":null,"order":8},{"name":"Dan Boyer","character":"Self - Principal","img":null,"order":9},{"name":"Khloe Wilson","character":"Self","img":null,"order":10},{"name":"Craig Wilson","character":"Self","img":null,"order":11},{"name":"Tami Wilson","character":"Self","img":null,"order":12},{"name":"Korin Weber","character":"Self","img":null,"order":13},{"name":"Bradley Peter","character":"Self - FBI Liason","img":null,"order":14}],"movie:977942":[{"name":"Andrew Garfield","character":"Ploughman","img":null,"order":0},{"name":"Jamie Bell","character":"John Ball","img":null,"order":1},{"name":"Stephen Dillane","character":"Archbishop Sudbury","img":null,"order":2},{"name":"Woody Norman","character":"King Richard II","img":null,"order":3},{"name":"Tom Hollander","character":"Treasurer Hales","img":null,"order":4},{"name":"Cosmo Jarvis","character":"Wat Tyler","img":null,"order":5},{"name":"Thomasin McKenzie","character":"Alyce","img":null,"order":6},{"name":"Jonny Lee Miller","character":"Mayor Walworth","img":null,"order":7},{"name":"Katherine Waterston","character":"Countess","img":null,"order":8},{"name":"Stanley Townsend","character":"Chief Justice Bealknap","img":null,"order":9},{"name":"Sky Yang","character":"Scribe","img":null,"order":10},{"name":"D\u00f3nal Finn","character":"Village Priest","img":null,"order":11},{"name":"Gerald Tyler","character":"Lord Beauchamp","img":null,"order":12},{"name":"Simon Kunz","character":"Sir John Newton","img":null,"order":13},{"name":"Mark Redhead","character":"Lord Peterborough","img":null,"order":14}],"movie:429197":[{"name":"Christian Bale","character":"Dick Cheney","img":null,"order":0},{"name":"Amy Adams","character":"Lynne Cheney","img":null,"order":1},{"name":"Steve Carell","character":"Donald Rumsfeld","img":null,"order":2},{"name":"Sam Rockwell","character":"George W. Bush","img":null,"order":3},{"name":"Alison Pill","character":"Mary Cheney","img":null,"order":4},{"name":"Eddie Marsan","character":"Paul Wolfowitz","img":null,"order":5},{"name":"Justin Kirk","character":"Scooter Libby","img":null,"order":6},{"name":"LisaGay Hamilton","character":"Condoleezza Rice","img":null,"order":7},{"name":"Jesse Plemons","character":"Kurt","img":null,"order":8},{"name":"Bill Camp","character":"Gerald Ford","img":null,"order":9},{"name":"Don McManus","character":"David Addington","img":null,"order":10},{"name":"Lily Rabe","character":"Liz Cheney","img":null,"order":11},{"name":"Shea Whigham","character":"Wayne Vincent","img":null,"order":12},{"name":"Stephen Adly Guirgis","character":"George Tenet","img":null,"order":13},{"name":"Tyler Perry","character":"Colin Powell","img":null,"order":14}],"movie:81":[{"name":"Sumi Shimamoto","character":"Nausica\u00e4 (voice)","img":null,"order":0},{"name":"Ichiro Nagai","character":"Mito (voice)","img":null,"order":1},{"name":"Gor\u014d Naya","character":"Yupa (voice)","img":null,"order":2},{"name":"Yoji Matsuda","character":"Asbel (voice)","img":null,"order":3},{"name":"Yoshiko Sakakibara","character":"Kushana (voice)","img":null,"order":4},{"name":"Iemasa Kayumi","character":"Kurotowa (voice)","img":null,"order":5},{"name":"Hisako Kyoda","character":"Obaba (voice)","img":null,"order":6},{"name":"Mahito Tsujimura","character":"Jihl (voice)","img":null,"order":7},{"name":"Mina Tominaga","character":"Lastel (voice)","img":null,"order":8},{"name":"Kohei Miyauchi","character":"Gol (voice)","img":null,"order":9},{"name":"Joji Yanami","character":"Gikkuri (voice)","img":null,"order":10},{"name":"Minoru Yada","character":"Niga (voice)","img":null,"order":11},{"name":"Rihoko Yoshida","character":"Teto / Girl C (voice)","img":null,"order":12},{"name":"Tetsuo Mizutori","character":"Commando (voice)","img":null,"order":13},{"name":"Masako Sugaya","character":"Girl A (voice)","img":null,"order":14}],"movie:1198984":[{"name":"Daisy Ridley","character":"Ava","img":null,"order":0},{"name":"Brenton Thwaites","character":"Clay","img":null,"order":1},{"name":"Mark Coles Smith","character":"Riley","img":null,"order":2},{"name":"Matt Whelan","character":"Mitch","img":null,"order":3},{"name":"Chloe Hurst","character":"Katie Harris","img":null,"order":4},{"name":"Kym Jackson","character":"Lt. Wilkie","img":null,"order":5},{"name":"Holly Hargreaves","character":"Mrs. Smith","img":null,"order":6},{"name":"Deanna Cooney","character":"Bianca","img":null,"order":7},{"name":"Elijah Williams","character":"Greg","img":null,"order":8},{"name":"Salme Geransar","character":"Private Clarkson","img":null,"order":9},{"name":"Luke Jai McIntosh","character":"Cadet","img":null,"order":10},{"name":"Joel Jackson","character":"Anxious Bus Volunteer","img":null,"order":11},{"name":"Dan Paris","character":"Captain Vance","img":null,"order":12},{"name":"Nicola Bartlett","character":"Woman on Plane","img":null,"order":13},{"name":"Kim Fleming","character":"Briefing Colonel","img":null,"order":14}],"movie:953371":[{"name":"Benjamin O'Keefe","character":"Self - Activist & Public Speaker","img":null,"order":0},{"name":"Bobby Blanski","character":"Self - Former A&F Model","img":null,"order":1},{"name":"Ryan Daharsh","character":"Self - Former A&F Model","img":null,"order":2},{"name":"Jennifer Sheahan","character":"Self - Former Store Employee","img":null,"order":3},{"name":"Dr. Anthony Ocampo","character":"Self - Former Store Employee","img":null,"order":4},{"name":"Sapna Maheshwari","character":"Self - Business Reporter, NY Times","img":null,"order":5},{"name":"Alan Karo","character":"Self - Fashion Marketing & Advertising Executive","img":null,"order":6},{"name":"Treva Lindsey","character":"Self - Professor of History, Ohio State University","img":null,"order":7},{"name":"Carla Barrientos","character":"Self - Former Store Employee","img":null,"order":8},{"name":"Robin Givhan","character":"Self - Senior Critic-at-Large, Washington Post","img":null,"order":9},{"name":"Jose Sanchez","character":"Self - Former A&F Recruiter","img":null,"order":10},{"name":"Moe Tkacik","character":"Self - Journalist","img":null,"order":11},{"name":"Mike Jeffries","character":"Self - Former A&F CEO (archive footage)","img":null,"order":12},{"name":"Charles Martin","character":"Self - Former A&F Design VP","img":null,"order":13},{"name":"Cindy Smith-Maglione","character":"Self - Former A&F VP of Merchandising","img":null,"order":14}],"movie:1184918":[{"name":"Lupita Nyong'o","character":"Roz / Rummage (voice)","img":null,"order":0},{"name":"Pedro Pascal","character":"Fink (voice)","img":null,"order":1},{"name":"Kit Connor","character":"Brightbill (voice)","img":null,"order":2},{"name":"Bill Nighy","character":"Longneck (voice)","img":null,"order":3},{"name":"Stephanie Hsu","character":"Vontra (voice)","img":null,"order":4},{"name":"Matt Berry","character":"Paddler (voice)","img":null,"order":5},{"name":"Ving Rhames","character":"Thunderbolt (voice)","img":null,"order":6},{"name":"Mark Hamill","character":"Thorn (voice)","img":null,"order":7},{"name":"Catherine O'Hara","character":"Pinktail (voice)","img":null,"order":8},{"name":"Boone Storm","character":"Baby Brightbill (voice)","img":null,"order":9},{"name":"Alexandra Novelle","character":"Snowdown (voice)","img":null,"order":10},{"name":"Raphael Alejandro","character":"Peck (voice)","img":null,"order":11},{"name":"Paul-Mik\u00e9l Williams","character":"Feather (voice)","img":null,"order":12},{"name":"Eddie Park","character":"Honkington (voice)","img":null,"order":13},{"name":"Dee Bradley Baker","character":"Raccoons / Squirrels / Badgers (voice)","img":null,"order":14}],"movie:149870":[{"name":"Hideaki Anno","character":"Jiro Horikoshi (voice)","img":null,"order":0},{"name":"Hidetoshi Nishijima","character":"Honj\u00f4 (voice)","img":null,"order":1},{"name":"Miori Takimoto","character":"Naoko Satomi (voice)","img":null,"order":2},{"name":"Masahiko Nishimura","character":"Kurokawa (voice)","img":null,"order":3},{"name":"Stephen Alpert","character":"Castorp (voice)","img":null,"order":4},{"name":"Mansai Nomura","character":"Giovanni Battista (voice)","img":null,"order":5},{"name":"Jun Kunimura","character":"Hattori (voice)","img":null,"order":6},{"name":"Mirai Shida","character":"Kayo Horikoshi (voice)","img":null,"order":7},{"name":"Shinobu Otake","character":"Mrs. Kurokawa (voice)","img":null,"order":8},{"name":"Morio Kazama","character":"Satomi (voice)","img":null,"order":9},{"name":"Keiko Takeshita","character":"Jir\u00f4's mother (voice)","img":null,"order":10},{"name":"Sascha","character":"German Security Guard / Flight Engineer (voice)","img":null,"order":11},{"name":"Kaichi Kaburagi","character":"Young Jiro Horikoshi (voice)","img":null,"order":12},{"name":"Maki Shinta","character":"Young Kayo Horikoshi (voice)","img":null,"order":13},{"name":"Berndt Otto","character":"(voice)","img":null,"order":14}],"movie:666243":[{"name":"Theo James","character":"Vesemir (voice)","img":null,"order":0},{"name":"Mary McDonnell","character":"Lady Zerbst (voice)","img":null,"order":1},{"name":"Lara Pulver","character":"Tetra Gilcrest (voice)","img":null,"order":2},{"name":"Graham McTavish","character":"Deglan (voice)","img":null,"order":3},{"name":"Tom Canton","character":"Filavandrel (voice)","img":null,"order":4},{"name":"David Errigo Jr.","character":"Young Vesemir / Young Remus (voice)","img":null,"order":5},{"name":"Jennifer Hale","character":"Illyana / Noblewoman (voice)","img":null,"order":6},{"name":"Kari Wahlgren","character":"Kitsu / Boy (voice)","img":null,"order":7},{"name":"Matthew Yang King","character":"Luka / Skogar (voice)","img":null,"order":8},{"name":"Darryl Kurylo","character":"Sven / Nobleman (voice)","img":null,"order":9},{"name":"Keith Ferguson","character":"Lord Carlisle / Reidrich (voice)","img":null,"order":10},{"name":"Jennie Kwan","character":"Young Luka (voice)","img":null,"order":11},{"name":"AJ LoCascio","character":"Young Sven (voice)","img":null,"order":12},{"name":"Michaela Dietz","character":"Tomas (voice)","img":null,"order":13},{"name":"Harry Hissrich","character":"Young Boy (Geralt) (voice)","img":null,"order":14}],"movie:1203329":[{"name":"Doug Cockle","character":"Geralt (voice)","img":null,"order":0},{"name":"Joey Batey","character":"Jaskier / Young Jaskier (voice)","img":null,"order":1},{"name":"Christina Wren","character":"Essi (voice)","img":null,"order":2},{"name":"Emily Carey","character":"Sh'eenaz (voice)","img":null,"order":3},{"name":"Camrus Johnson","character":"Agloval (voice)","img":null,"order":4},{"name":"Simon Templeman","character":"King Usveldt (voice)","img":null,"order":5},{"name":"Mallory Jansen","character":"Melusina / Princess Nispen (voice)","img":null,"order":6},{"name":"Anya Chalotra","character":"Yennefer (voice)","img":null,"order":7},{"name":"Ray Chase","character":"Zelest (voice)","img":null,"order":8},{"name":"Cynthia Kaye McWilliams","character":"Queen Dahut (voice)","img":null,"order":9},{"name":"Ramon Tikaram","character":"King Basim (voice)","img":null,"order":10},{"name":"Kari Wahlgren","character":"Zmarra / Vodnik Assassin (voice)","img":null,"order":11},{"name":"Stephen Fu","character":"Deroua (voice)","img":null,"order":12},{"name":"George Ackles","character":"Drouhard (voice)","img":null,"order":13},{"name":"Brian George","character":"Barkeep / Herald (voice)","img":null,"order":14}],"tv:260458":[{"name":"Malik Elassal","character":"Samir Rahman","img":null,"order":0},{"name":"Lucy Freyer","character":"Billie Schaeffer","img":null,"order":1},{"name":"Jack Innanen","character":"Paul Baker","img":null,"order":2},{"name":"Amita Rao","character":"Issa Damani","img":null,"order":3},{"name":"Owen Thiele","character":"Anton Evans","img":null,"order":4},{"name":"Julia Fox","character":"Julia Fox","img":null,"order":500},{"name":"Rachel Marsh","character":"Carly","img":null,"order":502},{"name":"Charlie Cox","character":"Mr. Teacher","img":null,"order":521},{"name":"Julian Manjerico","character":"Zack-Carlos","img":null,"order":576},{"name":"Martha Girvin","character":"Serena","img":null,"order":582},{"name":"Andrea Pavlovic","character":"Lily","img":null,"order":585},{"name":"Meghan Swaby","character":"Mary","img":null,"order":587},{"name":"Liam Cullagh","character":"OJ","img":null,"order":594},{"name":"Will Sennett","character":"Roach","img":null,"order":595},{"name":"Joey Dardano","character":"Schnee","img":null,"order":596}],"tv:64513":[{"name":"Sterling K. Brown","character":"Christopher Darden","img":null,"order":0},{"name":"Kenneth Choi","character":"Lance Ito","img":null,"order":3},{"name":"Beanie Feldstein","character":"Monica Lewinsky","img":null,"order":5},{"name":"Christian Clemenson","character":"Bill Hodgman","img":null,"order":7},{"name":"Sarah Paulson","character":"Linda Tripp","img":null,"order":9},{"name":"Cuba Gooding Jr.","character":"O. J. Simpson","img":null,"order":9},{"name":"Margo Martindale","character":"Lucianne Goldberg","img":null,"order":11},{"name":"Bruce Greenwood","character":"Gil Garcetti","img":null,"order":12},{"name":"Edie Falco","character":"Hillary Clinton","img":null,"order":13},{"name":"Nathan Lane","character":"F. Lee Bailey","img":null,"order":14},{"name":"Clive Owen","character":"Bill Clinton","img":null,"order":15},{"name":"David Schwimmer","character":"Robert Kardashian","img":null,"order":18},{"name":"John Travolta","character":"Robert Shapiro","img":null,"order":19},{"name":"Courtney B. Vance","character":"Johnnie Cochran","img":null,"order":20},{"name":"Annaleigh Ashford","character":"Elizabeth Cote","img":null,"order":398}],"tv:215866":[{"name":"Taylor Kitsch","character":"Isaac Reed","img":null,"order":0},{"name":"Betty Gilpin","character":"Sara Rowell","img":null,"order":1},{"name":"Dane DeHaan","character":"Jacob Pratt","img":null,"order":2},{"name":"Saura Lightfoot-Leon","character":"Abish Pratt","img":null,"order":3},{"name":"Derek Hinkey","character":"Red Feather","img":null,"order":4},{"name":"Joe Tippett","character":"James Wolsey","img":null,"order":5},{"name":"Jai Courtney","character":"Virgil Cutter","img":null,"order":6},{"name":"Preston Mota","character":"Devin Rowell","img":null,"order":7},{"name":"Shawnee Pourier","character":"Two Moons","img":null,"order":8},{"name":"Shea Whigham","character":"Jim Bridger","img":null,"order":9},{"name":"Kyle Davis","character":"Tilly","img":null,"order":500},{"name":"Tokala Black Elk","character":"Buffalo Run","img":null,"order":501},{"name":"Nick Hargrove","character":"Cottrell","img":null,"order":502},{"name":"Alex Fine","character":"Gant","img":null,"order":504},{"name":"Andrew P. Logan","character":"Lucas","img":null,"order":506}],"tv:4589":[{"name":"Jason Bateman","character":"Michael Bluth","img":null,"order":0},{"name":"Portia de Rossi","character":"Lindsay Bluth F\u00fcnke","img":null,"order":1},{"name":"Will Arnett","character":"Gob Bluth","img":null,"order":2},{"name":"Michael Cera","character":"George Michael Bluth","img":null,"order":3},{"name":"Alia Shawkat","character":"Maeby F\u00fcnke","img":null,"order":4},{"name":"Tony Hale","character":"Byron 'Buster' Bluth","img":null,"order":5},{"name":"David Cross","character":"Tobias F\u00fcnke","img":null,"order":6},{"name":"Jeffrey Tambor","character":"George Bluth Sr.","img":null,"order":7},{"name":"Jessica Walter","character":"Lucille Bluth","img":null,"order":8},{"name":"Ron Howard","character":"Narrator","img":null,"order":9},{"name":"Justin Grant Wade","character":"Steve Holt","img":null,"order":508},{"name":"Liza Minnelli","character":"Lucille Austero","img":null,"order":520},{"name":"Mae Whitman","character":"Ann Veal","img":null,"order":533},{"name":"John Beard","character":"John Beard","img":null,"order":609},{"name":"Henry Winkler","character":"Barry Zuckerkorn","img":null,"order":635}],"tv:246":[{"name":"Zach Tyler Eisen","character":"Aang (voice)","img":null,"order":0},{"name":"Mae Whitman","character":"Katara (voice)","img":null,"order":1},{"name":"Jack De Sena","character":"Sokka (voice)","img":null,"order":2},{"name":"Dante Basco","character":"Zuko (voice)","img":null,"order":3},{"name":"Mako","character":"Iroh (voice)","img":null,"order":4},{"name":"Dee Bradley Baker","character":"Appa / Momo (voice)","img":null,"order":7},{"name":"Cricket Leigh","character":"Mai (voice)","img":null,"order":9},{"name":"Olivia Hack","character":"Ty Lee (voice)","img":null,"order":10},{"name":"Michaela Jill Murphy","character":"Toph Beifong (voice)","img":null,"order":173},{"name":"Andr\u00e9 Sogliuzzo","character":"Captain (voice)","img":null,"order":378},{"name":"Grey DeLisle","character":"Azula / Additional Voices (voice)","img":null,"order":491},{"name":"Greg Baldwin","character":"Iroh / Combustion Man (voice)","img":null,"order":545},{"name":"Mark Hamill","character":"Ozai (voice)","img":null,"order":581},{"name":"Jennie Kwan","character":"Suki (voice)","img":null,"order":624},{"name":"Jim Meskimen","character":"Avatar Kuruk (voice)","img":null,"order":657}],"tv:124428":[{"name":"Jeroen Kijk in de Vegte","character":"Voice-over","img":null,"order":0},{"name":"Martijn Bolhoeve","character":"Self - Owner of a B&B in Hua Hin, Thailand","img":null,"order":2},{"name":"Joy van Swieten","character":"Self - Owner of a B&B in Malaga, Spain","img":null,"order":3},{"name":"Marian Trott","character":"Self - Owner of a B&B in Budens, Portugal","img":null,"order":4},{"name":"Bram Eijgenhuijsen","character":"Self - Owner of a B&B in Norrhult, Sweden","img":null,"order":5},{"name":"Debbie de Jong","character":"Self - Owner of a B&B in Sant Andreu del Terri, Spain","img":null,"order":6},{"name":"Leendert Wijland","character":"Self - Owner of a B&B in La Roche-En-Ardenne, Belgium","img":null,"order":7},{"name":"Petri Damen","character":"Self - Owner of a B&B in Monclar, France","img":null,"order":8},{"name":"Walter Spangenberg","character":"Self - Owner of a B&B in Troche, France","img":null,"order":9},{"name":"Iris Villas","character":"Self - Owner of a B&B in Sparto, Greece","img":null,"order":13},{"name":"Robert Jan van Rheenen","character":"Self - Owner of a B&B in Dornoch, Scotland","img":null,"order":14},{"name":"Arno van Wilpe","character":"Self - Owner of a B&B in Payzac, France","img":null,"order":15},{"name":"Mike Verkoelen","character":"Self - Owner of a B&B in Ma\u00e7anet de la Selva, Spain","img":null,"order":16},{"name":"Paul Visser","character":"Self - Partner of Debbie de Jong","img":null,"order":48},{"name":"Dani Zijlstra","character":"Self - Partner of Joy van Swieten","img":null,"order":49}],"tv:136315":[{"name":"Jeremy Allen White","character":"Carmen 'Carmy' Berzatto","img":null,"order":0},{"name":"Ebon Moss-Bachrach","character":"Richard 'Richie' Jerimovich","img":null,"order":1},{"name":"Ayo Edebiri","character":"Sydney Adamu","img":null,"order":2},{"name":"Lionel Boyce","character":"Marcus Brooks","img":null,"order":3},{"name":"Abby Elliott","character":"Natalie 'Sugar' Berzatto","img":null,"order":4},{"name":"Matty Matheson","character":"Neil Fak","img":null,"order":5},{"name":"Liza Col\u00f3n-Zayas","character":"Bettina 'Tina' Marrero","img":null,"order":6},{"name":"Edwin Lee Gibson","character":"Ebraheim","img":null,"order":8},{"name":"Oliver Platt","character":"Uncle Jimmy 'Cicero' Kalinowski","img":null,"order":500},{"name":"Christopher Zucchero","character":"Chi-Chi","img":null,"order":505},{"name":"Chris Witaske","character":"Pete Katinsky","img":null,"order":513},{"name":"Corey Hendrix","character":"Gary 'Sweeps' Woods","img":null,"order":536},{"name":"Jose M. Cervantes","character":"Angel","img":null,"order":537},{"name":"Richard Esteras","character":"Manny","img":null,"order":538},{"name":"Ricky Staffieri","character":"Theodore Fak","img":null,"order":548}],"tv:74313":[{"name":"David Attenborough","character":"Self - Presenter","img":null,"order":0}],"tv:93290":[{"name":"Martin Freeman","character":"Paul Worsley","img":null,"order":0},{"name":"Daisy Haggard","character":"Ally Grant","img":null,"order":1},{"name":"Eve Prenelle","character":"Ava Worsley","img":null,"order":4},{"name":"Alex Eastwood","character":"Luke Worsley","img":null,"order":5},{"name":"Oscar Kennedy","character":"Luke Worsley","img":null,"order":6},{"name":"Zo\u00eb Athena","character":"Ava Worsley","img":null,"order":7},{"name":"George Wakeman","character":"Luke Worsley","img":null,"order":345},{"name":"Jayda Eyles","character":"Young Ava","img":null,"order":346},{"name":"Tim Steed","character":"Carl","img":null,"order":501},{"name":"Patrick Baladi","character":"Darren","img":null,"order":504},{"name":"Alun Armstrong","character":"Jim","img":null,"order":514},{"name":"Joanna Bacon","character":"Jackie","img":null,"order":515},{"name":"Stella Gonet","character":"Leah","img":null,"order":516},{"name":"Hugh Quarshie","character":"Alex","img":null,"order":519},{"name":"Aliyah Sesay","character":"Grace","img":null,"order":520}],"tv:87108":[{"name":"Jared Harris","character":"Valery Legasov","img":null,"order":0},{"name":"Stellan Skarsg\u00e5rd","character":"Boris Shcherbina","img":"mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","order":1},{"name":"Emily Watson","character":"Ulana Khomyuk","img":null,"order":2},{"name":"Paul Ritter","character":"Anatoly Dyatlov","img":null,"order":3},{"name":"Jessie Buckley","character":"Lyudmilla Ignatenko","img":null,"order":4},{"name":"Adam Nagaitis","character":"Vasily Ignatenko","img":null,"order":5},{"name":"Sam Troughton","character":"Alexandr Akimov","img":null,"order":6},{"name":"Robert Emms","character":"Leonid Toptunov","img":null,"order":7},{"name":"Con O'Neill","character":"Viktor Bryukhanov","img":null,"order":8},{"name":"Adrian Rawlins","character":"Nikolai Fomin","img":null,"order":9},{"name":"Alan Williams","character":"KGB Chairman Charkov","img":null,"order":10},{"name":"David Dencik","character":"Mikhail Gorbachev","img":null,"order":11},{"name":"Mark Lewis Jones","character":"General Pikalov","img":null,"order":12},{"name":"Ralph Ineson","character":"General Tarakanov","img":null,"order":13},{"name":"Barry Keoghan","character":"Pavel","img":null,"order":14}],"tv:18347":[{"name":"Joel McHale","character":"Jeff Winger","img":null,"order":0},{"name":"Gillian Jacobs","character":"Britta Perry","img":null,"order":1},{"name":"Alison Brie","character":"Annie Edison","img":null,"order":2},{"name":"Donald Glover","character":"Troy Barnes","img":null,"order":3},{"name":"Yvette Nicole Brown","character":"Shirley Bennett","img":null,"order":4},{"name":"Jim Rash","character":"Dean Craig Pelton","img":null,"order":5},{"name":"Danny Pudi","character":"Abed Nadir","img":null,"order":6},{"name":"Chevy Chase","character":"Pierce Hawthorne","img":null,"order":7},{"name":"Ken Jeong","character":"Ben Chang","img":null,"order":10},{"name":"John Oliver","character":"Professor Ian Duncan","img":null,"order":154},{"name":"Charley Koontz","character":"Neil","img":null,"order":1003},{"name":"Danielle Kaplowitz","character":"Vicki","img":null,"order":1034},{"name":"Erik Charles Nielsen","character":"Garrett Lambert","img":null,"order":1035},{"name":"Dino Stamatopoulos","character":"Star-Burns","img":null,"order":1046},{"name":"Richard Erdman","character":"Leonard","img":null,"order":1049}],"tv:289517":[{"name":"Lee Jae-wook","character":"Byeon Hyeok-jun","img":null,"order":0},{"name":"Go Min-si","character":"Eum Yeon-joo","img":null,"order":1},{"name":"Kim Min-ha","character":"Byeon Ji-yoon","img":null,"order":2},{"name":"Lee Hee-jun","character":"Head of Spider Staffing Agency","img":null,"order":3},{"name":"Choi Hyun-wook","character":"Choi Hee-dong","img":null,"order":500},{"name":"Yoon Kyung-ho","character":"Jo Jun-seop","img":null,"order":501},{"name":"Cho Hyun-chul","character":"Cinephile","img":null,"order":502},{"name":"Yoon Byung-hee","character":"Theater Manager","img":null,"order":503},{"name":"Seo Jae-hee","character":"Director of Yangcheon Bio Clinic","img":null,"order":504},{"name":"Ko Chang-seok","character":"Foreman","img":null,"order":505},{"name":"Kim Si-eun","character":"Woman on the bridge","img":null,"order":506}],"tv:110695":[{"name":"Michael Keaton","character":"Samuel Finnix","img":null,"order":0},{"name":"Peter Sarsgaard","character":"Rick Mountcastle","img":null,"order":1},{"name":"Michael Stuhlbarg","character":"Richard Sackler","img":null,"order":2},{"name":"Will Poulter","character":"Billy Cutler","img":null,"order":3},{"name":"John Hoogenakker","character":"Randy Ramseyer","img":null,"order":4},{"name":"Kaitlyn Dever","character":"Betsy Mallum","img":null,"order":5},{"name":"Rosario Dawson","character":"Bridget Meyer","img":null,"order":6},{"name":"Will Chase","character":"Michael Friedman","img":null,"order":7},{"name":"Cleopatra Coleman","character":"Grace Pell","img":null,"order":8},{"name":"Parker Sack","character":"Dirk","img":null,"order":9},{"name":"Mare Winningham","character":"Diane Mallum","img":null,"order":504},{"name":"Ian Unterman","character":"Jonathan Sackler","img":null,"order":505},{"name":"Jake McDorman","character":"John Brownlee","img":null,"order":506},{"name":"Phillipa Soo","character":"Amber Collins","img":null,"order":507},{"name":"Brendan Patrick Connor","character":"Howard Udell","img":null,"order":508}],"tv:90228":[{"name":"Emily Watson","character":"Mother Superior Valya Harkonnen","img":null,"order":0},{"name":"Olivia Williams","character":"Reverend Mother Tula Harkonnen","img":null,"order":1},{"name":"Travis Fimmel","character":"Desmond Hart","img":null,"order":2},{"name":"Jodhi May","character":"Empress Natalya Arat Corrino","img":null,"order":3},{"name":"Sarah-Sofie Boussnina","character":"Princess Ynez Corrino","img":null,"order":4},{"name":"Chloe Lea","character":"Sister Lila","img":null,"order":5},{"name":"Chris Mason","character":"Keiran Atreides","img":null,"order":6},{"name":"Shalom Brune-Franklin","character":"Mikaela","img":null,"order":7},{"name":"Jade Anouka","character":"Sister Theodosia","img":null,"order":9},{"name":"Edward Davis","character":"Harrow Harkonnen","img":null,"order":10},{"name":"Josh Heuston","character":"Constantine Corrino","img":null,"order":11},{"name":"Faoileann Cunningham","character":"Sister Jen","img":null,"order":12},{"name":"Aoife Hinds","character":"Sister Emeline","img":null,"order":13},{"name":"Indira Varma","character":"","img":null,"order":14},{"name":"Ashley Walters","character":"","img":null,"order":15}],"tv:258165":[{"name":"Florence Pugh","character":"Cathy Ames","img":null,"order":0},{"name":"Christopher Abbott","character":"Adam Trask","img":null,"order":1},{"name":"Mike Faist","character":"Charles Trask","img":null,"order":2},{"name":"Joseph Zada","character":"Caleb Trask","img":null,"order":253},{"name":"Robert Morgan","character":"Sheriff Quinn","img":null,"order":500},{"name":"Christopher Sommers","character":"Mr. Bacon","img":null,"order":501},{"name":"Brad McMurray","character":"Joe Valery","img":null,"order":502},{"name":"Vaughan Reilly","character":"Adolescent Cathy","img":null,"order":503},{"name":"Hoon Lee","character":"Lee","img":null,"order":505},{"name":"Ciar\u00e1n Hinds","character":"Samuel Hamilton","img":null,"order":506},{"name":"Tandi Wright","character":"Mrs. Garb","img":null,"order":507},{"name":"Anita Hegh","character":"Liza Hamilton","img":null,"order":508},{"name":"Oscar Redding","character":"Rabbit Holman","img":null,"order":509},{"name":"Amelia Reynolds","character":"Ethel","img":null,"order":510},{"name":"Jonah Wren Phillips","character":"Young Aron Trask","img":null,"order":512}],"tv:106379":[{"name":"Ella Purnell","character":"Lucy MacLean","img":null,"order":0},{"name":"Aaron Moten","character":"Maximus","img":null,"order":1},{"name":"Mois\u00e9s Arias","character":"Norm MacLean","img":null,"order":2},{"name":"Frances Turner","character":"Barb Howard","img":null,"order":3},{"name":"Kyle MacLachlan","character":"Overseer Hank MacLean","img":null,"order":4},{"name":"Walton Goggins","character":"The Ghoul / Cooper Howard","img":null,"order":5},{"name":"Leslie Uggams","character":"Betty Pearson","img":null,"order":501},{"name":"Michael Cristofer","character":"Elder Cleric Quintus","img":null,"order":502},{"name":"Johnny Pemberton","character":"Thaddeus","img":null,"order":503},{"name":"Zach Cherry","character":"Woody Thomas","img":null,"order":506},{"name":"Annabel O'Hagan","character":"Stephanie Harper","img":null,"order":507},{"name":"Dave Register","character":"Chet","img":null,"order":508},{"name":"Rodrigo Luzzi","character":"Reg McPhee","img":null,"order":509},{"name":"Leer Leary","character":"Davey","img":null,"order":515},{"name":"Amir Carr","character":"Young Maximus","img":null,"order":527}],"tv:2207":[{"name":"John Cleese","character":"Basil Fawlty","img":null,"order":0},{"name":"Prunella Scales","character":"Sybil Fawlty","img":null,"order":1},{"name":"Connie Booth","character":"Polly Sherman","img":null,"order":2},{"name":"Andrew Sachs","character":"Manuel","img":null,"order":3},{"name":"Ballard Berkeley","character":"Major Gowen","img":null,"order":4},{"name":"Brian Hall","character":"Terry the Chef","img":null,"order":5},{"name":"Gilly Flower","character":"Miss Tibbs","img":null,"order":6},{"name":"Renee Roberts","character":"Miss Gatsby","img":null,"order":7},{"name":"Michael Gwynn","character":"Lord Melbury","img":null,"order":500},{"name":"Diana King","character":"Mrs. Rachel Lloyd","img":null,"order":501},{"name":"David Kelly","character":"O'Reilly","img":null,"order":502},{"name":"Brenda Cowling","character":"Sister","img":null,"order":503},{"name":"Elizabeth Benson","character":"Mrs. Heath","img":null,"order":540},{"name":"Terence Conoley","character":"Mr. Johnston","img":null,"order":548},{"name":"George Lee","character":"Delivery Man","img":null,"order":565}],"tv:290720":[{"name":"Jose Coronado","character":"Basil","img":null,"order":0},{"name":"Mar\u00eda Valverde","character":"Adela Figueora","img":null,"order":1},{"name":"Marti\u00f1o Rivas","character":"Fox\u00e1","img":null,"order":2},{"name":"Maribel Verd\u00fa","character":"Raquel Auslander","img":null,"order":3},{"name":"Gonzalo de Castro","character":"Fonseca","img":null,"order":4},{"name":"Cristina Kovani","character":"Vicky","img":null,"order":5},{"name":"Jos\u00e9 Condessa","character":"Toni","img":null,"order":6},{"name":"Pep\u00f3n Nieto","character":"Seruya","img":null,"order":7},{"name":"Daniel Holgu\u00edn","character":"Gerard","img":null,"order":8},{"name":"Michael Stobbe","character":"Se\u00f1or Klemmer","img":null,"order":9},{"name":"Yaiza Guimar\u00e9","character":"Se\u00f1ora Klemmer","img":null,"order":10},{"name":"Almudena Salort","character":"Catalina","img":null,"order":11},{"name":"Paloma Bloyd","character":"Elisa","img":null,"order":12}],"tv:243316":[{"name":"Tina Fey","character":"Kate","img":null,"order":0},{"name":"Steve Carell","character":"Nick Pagano","img":null,"order":1},{"name":"Colman Domingo","character":"Danny","img":null,"order":2},{"name":"Erika Henningsen","character":"Ginny","img":null,"order":3},{"name":"Kerri Kenney","character":"Anne Pagano","img":null,"order":4},{"name":"Will Forte","character":"Jack","img":null,"order":5},{"name":"Marco Calvani","character":"Claude","img":null,"order":6},{"name":"Julia Lester","character":"Lila","img":null,"order":502},{"name":"Alan Alda","character":"Don","img":null,"order":503},{"name":"Ashlyn Maddox","character":"Beth","img":null,"order":509},{"name":"Eric Elizaga","character":"Desk Clerk","img":null,"order":513},{"name":"Toby Huss","character":"Terry","img":null,"order":519},{"name":"Blake Russell","character":"State Trooper","img":null,"order":528},{"name":"Steven Pasquale","character":"Mark Brett","img":null,"order":531},{"name":"Brielle Pribbernow","character":"Boardwalk Kid","img":null,"order":532}],"tv:65345":[{"name":"Justice Smith","character":"Ezekiel Figuero","img":null,"order":0},{"name":"Shameik Moore","character":"Shaolin Fantastic","img":null,"order":1},{"name":"Herizen F. Guardiola","character":"Mylene Cruz","img":null,"order":2},{"name":"Skylan Brooks","character":"Ra-Ra Kipling","img":null,"order":3},{"name":"Jaden Smith","character":"Marcus 'Dizzee' Kipling","img":null,"order":4},{"name":"Tremaine Brown Jr.","character":"Boo-Boo Kipling","img":null,"order":5},{"name":"Yahya Abdul-Mateen II","character":"Clarence 'Cadillac' Caldwell","img":null,"order":6},{"name":"Jimmy Smits","character":"Francisco 'Papa Fuerte' Cruz","img":null,"order":7},{"name":"Stefan\u00e9e Martin","character":"Yolanda Kipling","img":null,"order":8},{"name":"Shyrley Rodriguez","character":"Regina Diaz","img":null,"order":9},{"name":"Mamoudou Athie","character":"Grandmaster Flash","img":null,"order":10},{"name":"Giancarlo Esposito","character":"Pastor Ramon Cruz","img":null,"order":11},{"name":"Ron Cephas Jones","character":"Winston Kipling","img":null,"order":505},{"name":"RayJonaldy Rodriguez","character":"Silent Carlito","img":null,"order":506},{"name":"Zuri Reed","character":"Zulu Girl Yvonne","img":null,"order":508}],"tv:204304":[{"name":"Michael Pollan","character":"Self","img":null,"order":0},{"name":"Ann Shulgin","character":"Self - Author, Mentor & Guide Therapist","img":null,"order":500},{"name":"Alexander Shulgin","character":"Self - Chemist, Author & Professor (archive footage)","img":null,"order":501},{"name":"Rick Doblin","character":"Self - Founder, MAPS","img":null,"order":502},{"name":"Paul F. Daley","character":"Self","img":null,"order":503}],"tv:96488":[{"name":"Peter Sarsgaard","character":"David Russell","img":null,"order":0},{"name":"Kyle Gallner","character":"Eric Fisher","img":null,"order":1},{"name":"David Strathairn","character":"Henry Fisher","img":null,"order":2},{"name":"Kodi Smit-McPhee","character":"Chris Keller","img":null,"order":3},{"name":"Jersey Johnston","character":"Debbie Russell","img":null,"order":533},{"name":"Frank Whaley","character":"Detective James Connor","img":null,"order":577},{"name":"Ellen Humphreys","character":"Sharon Russell","img":null,"order":578},{"name":"Melinda McGraw","character":"Faith Turner","img":null,"order":579},{"name":"Chad L. Coleman","character":"Mr. Franklin","img":null,"order":604},{"name":"Joanna Going","character":"Mrs. Fisher","img":null,"order":621},{"name":"Ebon Moss-Bachrach","character":"Trey Carano","img":null,"order":622},{"name":"Autry Haydon-Wilson","character":"Young Amy Harlow","img":null,"order":623},{"name":"Vincent D'Onofrio","character":"Sgt. Ian Lynch","img":null,"order":624},{"name":"Andre Royo","character":"Charlie Shannon","img":null,"order":625},{"name":"Barbie Robertson","character":"Debbie Russell","img":null,"order":636}],"tv:2710":[{"name":"Charlie Day","character":"Charlie Kelly","img":null,"order":0},{"name":"Glenn Howerton","character":"Dennis Reynolds","img":null,"order":1},{"name":"Rob Mac","character":"Ronald 'Mac' McDonald","img":null,"order":2},{"name":"Kaitlin Olson","character":"Dee Reynolds","img":null,"order":3},{"name":"Danny DeVito","character":"Frank Reynolds","img":null,"order":4},{"name":"Artemis Pebdani","character":"Artemis","img":null,"order":6},{"name":"Mary Elizabeth Ellis","character":"Waitress","img":null,"order":7},{"name":"David Hornsby","character":"Matthew 'Rickety Cricket' Mara","img":null,"order":8},{"name":"Lynne Marie Stewart","character":"Charlie's Mom","img":null,"order":216},{"name":"Sandy Martin","character":"Mac's Mom","img":null,"order":847},{"name":"Jimmi Simpson","character":"Liam McPoyle","img":null,"order":1009},{"name":"Nate Mooney","character":"Ryan McPoyle","img":null,"order":1011},{"name":"Andrew Friedman","character":"Uncle Jack","img":null,"order":1140},{"name":"Lance Barber","character":"Bill Ponderosa","img":null,"order":1201},{"name":"Gregory Scott Cummins","character":"Luther","img":null,"order":1230}],"tv:224372":[{"name":"Peter Claffey","character":"Ser Duncan 'Dunk' the Tall","img":null,"order":0},{"name":"Dexter Sol Ansell","character":"Egg","img":null,"order":1},{"name":"Ross Anderson","character":"Ser Humfrey Hardyng","img":null,"order":500},{"name":"Daniel Monks","character":"Ser Manfred Dondarrion","img":null,"order":504},{"name":"Tom Vaughan-Lawlor","character":"Plummer","img":null,"order":505},{"name":"Sam Spruell","character":"Prince Maekar Targaryen","img":null,"order":510},{"name":"Finn Bennett","character":"Prince Aerion 'Brightflame' Targaryen","img":null,"order":511},{"name":"Bertie Carvel","character":"Prince Baelor 'Breakspear' Targaryen","img":null,"order":512},{"name":"Danny Webb","character":"Ser Arlan of Pennytree","img":null,"order":513},{"name":"Tanzyn Crawford","character":"Tanselle","img":null,"order":514},{"name":"Henry Ashton","character":"Prince Daeron 'the Drunken' Targaryen","img":null,"order":517},{"name":"Edward Ashley","character":"Ser Steffon Fossoway","img":null,"order":518},{"name":"Cara Harris","character":"Lady Gwin","img":null,"order":527},{"name":"Shaun Thomas","character":"Ser Raymun 'The Reluctant' Fossoway","img":null,"order":532},{"name":"Daniel Ings","character":"Ser Lyonel 'The Laughing Storm' Baratheon","img":null,"order":543}],"tv:157741":[{"name":"Billy Bob Thornton","character":"Tommy Norris","img":null,"order":0},{"name":"Demi Moore","character":"Cami Miller","img":null,"order":1},{"name":"Andy Garcia","character":"Gallino","img":null,"order":2},{"name":"Ali Larter","character":"Angela Norris","img":null,"order":3},{"name":"Jacob Lofland","character":"Cooper Norris","img":null,"order":4},{"name":"Michelle Randolph","character":"Ainsley Norris","img":null,"order":5},{"name":"Paulina Ch\u00e1vez","character":"Ariana Medina","img":null,"order":6},{"name":"Kayla Wallace","character":"Rebecca Falcone","img":null,"order":7},{"name":"Mark Collie","character":"Sheriff Walt Joeberg","img":null,"order":8},{"name":"James Jordan","character":"Dale Bradley","img":null,"order":9},{"name":"Jon Hamm","character":"Monty Miller","img":null,"order":10},{"name":"Sam Elliott","character":"Thomas 'T.L.' Norris","img":null,"order":11},{"name":"Colm Feore","character":"Nathan","img":null,"order":501},{"name":"Mustafa Speaks","character":"Boss","img":null,"order":531},{"name":"Caleb Martin","character":"Ben 'BR' Reynolds","img":null,"order":611}],"tv:100088":[{"name":"Bella Ramsey","character":"Ellie Williams","img":null,"order":1},{"name":"Gabriel Luna","character":"Tommy Miller","img":null,"order":2},{"name":"Isabela Merced","character":"Dina","img":null,"order":3},{"name":"Young Mazino","character":"Jesse","img":null,"order":4},{"name":"Pedro Pascal","character":"Joel Miller","img":null,"order":164},{"name":"Anna Torv","character":"Tess","img":null,"order":523},{"name":"Lamar Johnson","character":"Henry Burrell","img":null,"order":565},{"name":"Rutina Wesley","character":"Maria","img":null,"order":567},{"name":"Samuel Hoeksema","character":"Museum Clicker","img":null,"order":571},{"name":"Kaitlyn Dever","character":"Abby Anderson","img":null,"order":576},{"name":"Danny Ramirez","character":"Manny Alvarez","img":null,"order":577},{"name":"Tati Gabrielle","character":"Nora","img":null,"order":578},{"name":"Spencer Lord","character":"Owen","img":null,"order":579},{"name":"Robert John Burke","character":"Seth","img":null,"order":580},{"name":"Ariela Barer","character":"Mel","img":null,"order":581}],"tv:252193":[{"name":"Junichi Okada","character":"Shujiro Saga","img":null,"order":0},{"name":"Yumia Fujisaki","character":"Futaba Katsuki","img":null,"order":1},{"name":"Kaya Kiyohara","character":"Iroha Kinugasa","img":null,"order":2},{"name":"Masahiro Higashide","character":"Kyojin Tsuge","img":null,"order":3},{"name":"Taichi Saotome","character":"Shikura Adashino","img":null,"order":4},{"name":"Yuya Endo","character":"Sansuke Gion","img":null,"order":5},{"name":"Hideaki Ito","character":"Bukotsu Kanjiya","img":null,"order":6},{"name":"Hiroshi Abe","character":"Okabe Gentosai","img":null,"order":7},{"name":"Gaku Hamada","character":"Kawaji Toshiyoshi","img":null,"order":501},{"name":"Arata Iura","character":"Okubo Toshimichi","img":null,"order":502},{"name":"Yuya Matsuura","character":"Kouemon Tachikawa","img":null,"order":504},{"name":"Nobi Nakanishi","character":"Narrator","img":null,"order":505},{"name":"Tetsushi Tanaka","character":"Hisoka Maejima","img":null,"order":506},{"name":"Kairi Jo","character":"Shinjiro Sayama","img":null,"order":514},{"name":"Taiiku Okazaki","character":"Keage Jinroku","img":null,"order":519}],"tv:270572":[{"name":"Winston Sawyers","character":"Ralph","img":null,"order":0},{"name":"David McKenna","character":"Nicholas \"Piggy\"","img":null,"order":1},{"name":"Lox Pratt","character":"Jack Merridew","img":null,"order":2},{"name":"Ike Talbut","character":"Simon Camborne","img":null,"order":3},{"name":"Thomas Connor","character":"Roger","img":null,"order":4},{"name":"Cornelius Brandreth","character":"Maurice","img":null,"order":5},{"name":"Cassius Flemyng","character":"Eric","img":null,"order":6},{"name":"Noah Flemyng","character":"Sam","img":null,"order":7},{"name":"Harrison Metcalf","character":"Frankie","img":null,"order":8},{"name":"Rafael de Belligny","character":"Robert","img":null,"order":9},{"name":"Felix Tandon","character":"William","img":null,"order":10},{"name":"Beau Thompson","character":"Philip","img":null,"order":11},{"name":"Kai Wilson","character":"Jasper","img":null,"order":12},{"name":"Freddie Lee-Grey","character":"Percival","img":null,"order":13},{"name":"Tom Page-Turner","character":"Bill","img":null,"order":14}],"tv:84773":[{"name":"Morfydd Clark","character":"Galadriel","img":null,"order":1},{"name":"Charlie Vickers","character":"Halbrand","img":null,"order":1},{"name":"Robert Aramayo","character":"Elrond","img":null,"order":3},{"name":"Charles Edwards","character":"Lord Celebrimbor","img":null,"order":4},{"name":"Owain Arthur","character":"Durin IV","img":null,"order":5},{"name":"Sophia Nomvete","character":"Disa","img":null,"order":6},{"name":"Daniel Weyman","character":"The Stranger","img":null,"order":7},{"name":"Markella Kavenagh","character":"Elanor 'Nori' Brandyfoot","img":null,"order":8},{"name":"Megan Richards","character":"Poppy Proudfellow","img":null,"order":9},{"name":"Ismael Cruz Cordova","character":"Arondir","img":null,"order":10},{"name":"Lloyd Owen","character":"Elendil","img":null,"order":11},{"name":"Benjamin Walker","character":"High King Gil-Galad","img":null,"order":16},{"name":"Cynthia Addai-Robinson","character":"M\u00edriel","img":null,"order":17},{"name":"Trystan Gravelle","character":"Pharaz\u00f4n","img":null,"order":18},{"name":"Ema Horvath","character":"E\u00e4rien","img":null,"order":19}],"tv:73411":[{"name":"Emma Stone","character":"Annie Landsberg","img":null,"order":0},{"name":"Jonah Hill","character":"Owen Milgrim","img":null,"order":1},{"name":"Sonoya Mizuno","character":"Dr. Azumi Fujita","img":null,"order":2},{"name":"Justin Theroux","character":"Dr. James K. Mantleray","img":null,"order":8},{"name":"Sally Field","character":"Dr. Greta Mantleray","img":null,"order":9},{"name":"Aaralyn Anderson","character":"Australia","img":null,"order":347},{"name":"James Monroe Iglehart","character":"Carl","img":null,"order":539},{"name":"Dai Ishiguro","character":"Head Control Tech","img":null,"order":540},{"name":"Gabriel Byrne","character":"Porter Milgrim","img":null,"order":546},{"name":"Kathleen Choe","character":"3","img":null,"order":547},{"name":"Allyce Beasley","character":"11","img":null,"order":550},{"name":"Stephen Hill","character":"7","img":null,"order":553},{"name":"Danny Hoch","character":"5","img":null,"order":556},{"name":"Billy Magnussen","character":"Jed Milgrim","img":null,"order":559},{"name":"Sejal Shah","character":"First Medical Tech","img":null,"order":562}],"tv:212333":[{"name":"Luca Marinelli","character":"Benito Mussolini","img":null,"order":0},{"name":"Francesco Russo","character":"Cesare Rossi","img":null,"order":1},{"name":"Barbara Chichiarelli","character":"Margherita Sarfatti","img":null,"order":2},{"name":"Benedetta Cimatti","character":"Rachele Mussolini","img":null,"order":3},{"name":"Gaetano Bruno","character":"Giacomo Matteotti","img":null,"order":4},{"name":"Maurizio Lombardi","character":"Emilio De Bono","img":null,"order":5},{"name":"Gianmarco Vettori","character":"Dino Grandi","img":null,"order":6},{"name":"Massimo De Lorenzo","character":"Alfredo Rocco","img":null,"order":7},{"name":"Fulvio Falzarano","character":"Giovanni Giolitti","img":null,"order":8},{"name":"Amedeo Gull\u00e0","character":"Augusto Malacria","img":null,"order":9},{"name":"Federico Majorana","character":"Amerigo Dumini","img":null,"order":500},{"name":"Lorenzo Zurzolo","character":"Italo Balbo","img":null,"order":501},{"name":"Vincenzo Nemolato","character":"King Vittorio Emanuele III","img":null,"order":502},{"name":"Giovanni Alfieri","character":"Amleto Poverno","img":null,"order":503},{"name":"Gabriele Falsetta","character":"Roberto Farinacci","img":null,"order":504}],"tv:61859":[{"name":"Tom Hiddleston","character":"Jonathan Pine","img":null,"order":0},{"name":"Hugh Laurie","character":"Richard Roper","img":null,"order":1},{"name":"Diego Calva","character":"Teddy Dos Santos","img":null,"order":2},{"name":"Camila Morrone","character":"Roxana Bola\u00f1os","img":null,"order":3},{"name":"Douglas Hodge","character":"Rex Mayhew","img":null,"order":5},{"name":"Elizabeth Debicki","character":"Jed Marshall","img":null,"order":6},{"name":"Paul Chahidi","character":"Basil Karapetian","img":null,"order":7},{"name":"David Harewood","character":"Joel Steadman","img":null,"order":8},{"name":"Hayley Squires","character":"Sally Price-Jones","img":null,"order":9},{"name":"Tom Hollander","character":"Lance Corkoran","img":null,"order":14},{"name":"Olivia Colman","character":"Angela Burr","img":"lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","order":19},{"name":"Michael Nardone","character":"Frisky","img":null,"order":20},{"name":"Alistair Petrie","character":"Sandy Langbourne","img":null,"order":21},{"name":"Aure Atika","character":"","img":null,"order":281},{"name":"Noah Jupe","character":"","img":null,"order":380}],"tv:111110":[{"name":"I\u00f1aki Godoy","character":"Monkey D. Luffy","img":null,"order":0},{"name":"Emily Rudd","character":"Nami","img":null,"order":1},{"name":"Mackenyu","character":"Roronoa Zoro","img":null,"order":2},{"name":"Jacob Gibson","character":"Usopp","img":null,"order":3},{"name":"Vincent Regan","character":"Vice-Admiral Garp","img":null,"order":4},{"name":"Jeff Ward","character":"Buggy the Clown","img":null,"order":5},{"name":"Taz Skylar","character":"Sanji","img":null,"order":6},{"name":"Charithra Chandran","character":"Miss Wednesday / Nefertari Vivi","img":null,"order":7},{"name":"Mikaela Hoover","character":"Tony Tony Chopper (voice)","img":null,"order":8},{"name":"Morgan Davies","character":"Koby","img":null,"order":10},{"name":"Lera Abova","character":"Miss All Sunday","img":null,"order":11},{"name":"Armand Aucamp","character":"Bogard","img":null,"order":510},{"name":"McKinley Belcher III","character":"Captain Arlong","img":null,"order":546},{"name":"Jandre le Roux","character":"Kuroobi","img":null,"order":551},{"name":"Aidan Scott","character":"Helmeppo","img":null,"order":592}],"tv:61617":[{"name":"Elijah Wood","character":"Wirt (voice)","img":null,"order":0},{"name":"Collin Dean","character":"Gregory (voice)","img":null,"order":1},{"name":"Melanie Lynskey","character":"Beatrice (voice)","img":null,"order":2},{"name":"Samuel Ramey","character":"The Beast (voice)","img":null,"order":3},{"name":"Christopher Lloyd","character":"The Woodsman (voice)","img":null,"order":4},{"name":"Janet Klein","character":"Miss Langtree (voice)","img":null,"order":500},{"name":"Deborah Voigt","character":"Queen of the Clouds / Additional Voices (voice)","img":null,"order":501},{"name":"Chris Isaak","character":"Enoch / Additional Voices (voice)","img":null,"order":503},{"name":"John Cleese","character":"Adelaide (voice)","img":null,"order":510},{"name":"Sam Marin","character":"Old Man Langtree (voice)","img":null,"order":512},{"name":"Emily Brundige","character":"Sara / Additional Voices (voice)","img":null,"order":519},{"name":"Fred Stoller","character":"Fred The Horse (voice)","img":null,"order":521},{"name":"Cole Sanchez","character":"Jason Funderberker / Additional Voices (voice)","img":null,"order":521},{"name":"Shirley Jones","character":"Beatrice's Mother / Additional Voices (voice)","img":null,"order":523},{"name":"Jack Jones","character":"Gregory's Frog (voice)","img":null,"order":526}],"tv:60574":[{"name":"Cillian Murphy","character":"Thomas Shelby","img":null,"order":0},{"name":"Paul Anderson","character":"Arthur Shelby","img":null,"order":1},{"name":"Joe Cole","character":"John Shelby","img":null,"order":2},{"name":"Helen McCrory","character":"Polly Gray","img":null,"order":4},{"name":"Sophie Rundle","character":"Ada Shelby","img":null,"order":6},{"name":"Natasha O'Keeffe","character":"Lizzie Stark","img":null,"order":12},{"name":"Finn Cole","character":"Michael Gray","img":null,"order":14},{"name":"Ian Peck","character":"Curly","img":null,"order":15},{"name":"Tom Hardy","character":"Alfie Solomons","img":"d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","order":16},{"name":"Ned Dennehy","character":"Charlie Strong","img":null,"order":17},{"name":"Harry Kirton","character":"Finn Shelby","img":null,"order":87},{"name":"Kate Phillips","character":"Linda Shelby","img":null,"order":104},{"name":"Annabelle Wallis","character":"Grace Shelby","img":null,"order":181},{"name":"Aimee-Ffion Edwards","character":"Esme Shelby","img":null,"order":606},{"name":"Packy Lee","character":"Johnny Dogs","img":null,"order":621}],"tv:250307":[{"name":"Noah Wyle","character":"Dr. Michael 'Robby' Robinavitch","img":null,"order":0},{"name":"Patrick Ball","character":"Dr. Frank Langdon","img":null,"order":2},{"name":"Katherine LaNasa","character":"Charge Nurse Dana Evans","img":null,"order":3},{"name":"Supriya Ganesh","character":"Dr. Samira Mohan","img":null,"order":4},{"name":"Fiona Dourif","character":"Dr. Cassie McKay","img":null,"order":5},{"name":"Taylor Dearden","character":"Dr. Melissa King","img":null,"order":6},{"name":"Isa Briones","character":"Dr. Trinity Santos","img":null,"order":7},{"name":"Gerran Howell","character":"Student Doctor Dennis Whitaker","img":null,"order":8},{"name":"Shabana Azeez","character":"Student Doctor Victoria Javadi","img":null,"order":9},{"name":"Alexandra Metz","character":"Dr. Yolanda Garcia","img":null,"order":508},{"name":"Ned Brower","character":"Nurse Jesse","img":null,"order":523},{"name":"Amielynn Abellera","character":"Nurse Perlah Alawi","img":null,"order":707},{"name":"Jalen Thomas Brooks","character":"Nurse Mateo Diaz","img":null,"order":708},{"name":"Brandon Mendez Homer","character":"Nurse Donald 'Donnie' Donahue","img":null,"order":709},{"name":"Kristin Villanueva","character":"Nurse Princess","img":null,"order":710}],"tv:225171":[{"name":"Rhea Seehorn","character":"Carol Sturka","img":null,"order":0},{"name":"Karolina Wydra","character":"Zosia","img":null,"order":1},{"name":"Carlos-Manuel Vesga","character":"Manousos Oviedo","img":null,"order":2},{"name":"Miriam Shor","character":"Helen","img":null,"order":500},{"name":"Samba Schutte","character":"Mr. Diabat\u00e9","img":null,"order":524},{"name":"Menik Gooneratne","character":"Laxmi","img":null,"order":525},{"name":"Darinka Arones","character":"Kusimayu","img":null,"order":526},{"name":"Anna Mhairi","character":"Vesper","img":null,"order":527},{"name":"Max Reeves","character":"Margaux","img":null,"order":528},{"name":"Olivia Rouyre","character":"Genevieve","img":null,"order":529},{"name":"Rayaan Kamal Khan","character":"Ravi","img":null,"order":532},{"name":"Elena Est\u00e9r","character":"T'ika","img":null,"order":537},{"name":"Soledad Campos","character":"Maternal Other","img":null,"order":559},{"name":"Jennifer Esquivel","character":"Sisa","img":null,"order":581},{"name":"Patrick Fabian","character":"Others' Phone Answering Service (voice) (uncredited)","img":null,"order":591}],"tv:94028":[{"name":"Andrew Scott","character":"Tom Ripley","img":"4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","order":0},{"name":"Dakota Fanning","character":"Marge Sherwood","img":null,"order":1},{"name":"Johnny Flynn","character":"Dickie Greenleaf","img":null,"order":2},{"name":"Kenneth Lonergan","character":"Herbert Greenleaf","img":null,"order":500},{"name":"Francesca Romana Bergamo","character":"Ermelinda","img":null,"order":511},{"name":"Pasquale Esposito","character":"Pucci","img":null,"order":512},{"name":"Arcangelo Iannace","character":"Giulio","img":null,"order":513},{"name":"Vittorio Viviani","character":"Matteo","img":null,"order":514},{"name":"Eliot Sumner","character":"Frederick 'Freddie' Miles","img":null,"order":515},{"name":"Bokeem Woodbine","character":"Alvin MacCarron","img":null,"order":516},{"name":"Renato Solpietro","character":"Carlo","img":null,"order":519},{"name":"Francesco Formichetti","character":"Sanremo Desk Clerk","img":null,"order":523},{"name":"Margherita Buy","character":"Sig. Silvana Buffi","img":null,"order":540},{"name":"Maurizio Lombardi","character":"Inspector Pietro Ravini","img":null,"order":544},{"name":"Gennaro Arena","character":"Naples Kid","img":null,"order":578}],"tv:245256":[{"name":"Lola Petticrew","character":"Dolours Price","img":null,"order":0},{"name":"Maxine Peake","character":"Older Dolours Price","img":null,"order":1},{"name":"Hazel Doupe","character":"Marian Price","img":null,"order":2},{"name":"Anthony Boyle","character":"Brendan Hughes","img":null,"order":3},{"name":"Tom Vaughan-Lawlor","character":"Older Brendan Hughes","img":null,"order":4},{"name":"Josh Finan","character":"Gerry Adams","img":null,"order":5},{"name":"Rory Kinnear","character":"Frank Kitson","img":null,"order":6},{"name":"Seamus O'Hara","character":"Mackers","img":null,"order":500},{"name":"Helen Behan","character":"Older Marian Price","img":null,"order":501},{"name":"Michael Colgan","character":"Older Gerry Adams","img":null,"order":502},{"name":"Conor O'Donnell","character":"Eamon","img":null,"order":503},{"name":"James Doherty","character":"Chief Inspector","img":null,"order":504},{"name":"Bill Jones","character":"Ian","img":null,"order":505},{"name":"Shannon Watson","character":"Make-up Artist","img":null,"order":506},{"name":"Emma Canning","character":"Kathleen","img":null,"order":508}],"tv:95396":[{"name":"Adam Scott","character":"Mark Scout","img":null,"order":0},{"name":"Britt Lower","character":"Helly Riggs","img":null,"order":1},{"name":"Tramell Tillman","character":"Seth Milchick","img":null,"order":2},{"name":"Zach Cherry","character":"Dylan George","img":null,"order":3},{"name":"Patricia Arquette","character":"Harmony Cobel","img":null,"order":4},{"name":"Jen Tullock","character":"Devon Scout-Hale","img":null,"order":5},{"name":"Michael Chernus","character":"Ricken Hale","img":null,"order":6},{"name":"Dichen Lachman","character":"Ms. Casey","img":null,"order":7},{"name":"Sarah Bock","character":"Eustice Huang","img":null,"order":8},{"name":"John Turturro","character":"Irving Bailiff","img":null,"order":9},{"name":"Christopher Walken","character":"Burt Goodman","img":null,"order":10},{"name":"Sydney Cole Alexander","character":"Natalie Kalen","img":null,"order":515},{"name":"Karen Aldridge","character":"Asal Regabhi","img":null,"order":522},{"name":"Michael Cumpsty","character":"Doug Graner","img":null,"order":529},{"name":"Claudia Robinson","character":"Felicia","img":null,"order":537}],"tv:1274":[{"name":"Peter Krause","character":"Nate Fisher","img":null,"order":0},{"name":"Michael C. Hall","character":"David Fisher","img":null,"order":1},{"name":"Frances Conroy","character":"Ruth Fisher","img":null,"order":2},{"name":"Lauren Ambrose","character":"Claire Fisher","img":null,"order":3},{"name":"Freddy Rodr\u00edguez","character":"Federico Diaz","img":null,"order":4},{"name":"Mathew St. Patrick","character":"Keith Charles","img":null,"order":5},{"name":"Rachel Griffiths","character":"Brenda Chenowith","img":null,"order":6},{"name":"Ben Foster","character":"Russell Corwin","img":null,"order":7},{"name":"Justina Machado","character":"Vanessa Diaz","img":null,"order":8},{"name":"Jeremy Sisto","character":"Billy Chenowith","img":null,"order":10},{"name":"Richard Jenkins","character":"Nathaniel Fisher","img":null,"order":11},{"name":"Lili Taylor","character":"Lisa Kimmel Fisher","img":null,"order":12},{"name":"James Cromwell","character":"George Sibley","img":null,"order":13},{"name":"Bronwyn Tosh","character":"Maya Fisher","img":null,"order":559},{"name":"Brenna Tosh","character":"Maya Fisher","img":null,"order":608}],"tv:86942":[{"name":"Tom Waes","character":"Bob Lemmens","img":null,"order":0},{"name":"Frank Lammers","character":"Ferry Bouman","img":null,"order":1},{"name":"Raymond Thiry","character":"John Zwart","img":null,"order":2},{"name":"Elise Schaap","character":"Danielle Bouman","img":null,"order":4},{"name":"Anna Drijver","character":"Kim De Rooij","img":null,"order":5},{"name":"Jeroen Van der Ven","character":"Patrick Dierickx","img":null,"order":6},{"name":"Ruth Becquart","character":"Nathalie Geudens","img":null,"order":7},{"name":"Manou Kersting","character":"Nick Janssens","img":null,"order":8},{"name":"Wim Willaert","character":"Laurent Berger","img":null,"order":10},{"name":"Sebastien Dewaele","character":"Jean-Pierre Berger","img":null,"order":11},{"name":"Emma Verlinden","character":"Polly","img":null,"order":187},{"name":"Katrien De Ruysscher","character":"Liesbeth Mertens","img":null,"order":553},{"name":"Robbie Cleiren","character":"Marc Gevers","img":null,"order":554},{"name":"Lieke van den Broek","character":"Sonja Van Kamp","img":null,"order":562},{"name":"Celest Henri Cornelis","character":"Jackson Geudens","img":null,"order":582}],"tv:86248":[{"name":"Robbie Amell","character":"Nathan Brown","img":null,"order":0},{"name":"Andy Allo","character":"Nora Antony","img":null,"order":1},{"name":"Allegra Edwards","character":"Ingrid Kannerman","img":null,"order":2},{"name":"Zainab Johnson","character":"Aleesha","img":null,"order":3},{"name":"Kevin Bigley","character":"Luke","img":null,"order":4},{"name":"Owen Daniels","character":"A.I. Guy","img":null,"order":5},{"name":"Andrea Rosen","character":"Lucy Slack","img":null,"order":6},{"name":"Josh Banday","character":"Ivan Spelich","img":null,"order":8},{"name":"Mackenzie Cardwell","character":"Tinsley","img":null,"order":9},{"name":"Andy Thompson","character":"Lionel Winter","img":null,"order":600},{"name":"Barclay Hope","character":"Oliver Kannerman","img":null,"order":607},{"name":"William B. Davis","character":"David Choak","img":null,"order":624},{"name":"Chloe Coleman","character":"Nevaeh","img":null,"order":632},{"name":"Yvetta Fisher","character":"Batia","img":null,"order":633},{"name":"Jessica Tuck","character":"Viv","img":null,"order":651}],"tv:210664":[{"name":"Stephen Graham","character":"Matthew Collins","img":null,"order":0},{"name":"Jason Flemyng","character":"Nick","img":null,"order":1},{"name":"Leanne Best","character":"Alison","img":null,"order":2},{"name":"Andrew Ellis","character":"Robbie","img":null,"order":3},{"name":"Chris Coghill","character":"Chris","img":null,"order":4},{"name":"Dean-Charles Chapman","character":"Jack","img":null,"order":5},{"name":"Sami Amber","character":"Billy","img":null,"order":6},{"name":"Steven Miller","character":"Young Lenny","img":null,"order":500}],"tv:270476":[{"name":"Matthew Rhys","character":"Mayor Tom Loftis","img":null,"order":0},{"name":"Kate O'Flynn","character":"Patricia Moyer","img":null,"order":1},{"name":"Kevin Carroll","character":"Sheriff Bechir Clemmons","img":null,"order":2},{"name":"Dale Dickey","character":"Rosemary","img":null,"order":3},{"name":"Kingston Rumi Southwick","character":"Evan Loftis","img":null,"order":4},{"name":"Stephen Root","character":"Wyck Crawford","img":null,"order":5},{"name":"Neil Casey","character":"Kurt","img":null,"order":500},{"name":"Jeff Hiller","character":"Dale","img":null,"order":501},{"name":"K Callan","character":"Ruth","img":null,"order":503},{"name":"Kylie McNeill","character":"Kelly","img":null,"order":504},{"name":"Beck Nolan","character":"PJ","img":null,"order":505},{"name":"Charles Van Flaherty","character":"Gil","img":null,"order":506},{"name":"Nancy Lenehan","character":"Gerrie","img":null,"order":508},{"name":"Christian Clemenson","character":"Dr. Morgan","img":null,"order":509},{"name":"Sipiwe Moyo","character":"Chelle","img":null,"order":529}],"tv:71912":[{"name":"Henry Cavill","character":"Geralt of Rivia","img":null,"order":0},{"name":"Anya Chalotra","character":"Yennefer of Vengerberg","img":null,"order":2},{"name":"Freya Allan","character":"Princess Cirilla 'Ciri' of Cintra","img":null,"order":3},{"name":"Joey Batey","character":"Jaskier / The Sandpiper","img":null,"order":4},{"name":"Eamon Farren","character":"Cahir","img":null,"order":5},{"name":"Mim\u00ee M. Khayisa","character":"Fringilla Vigo","img":null,"order":6},{"name":"Anna Shaffer","character":"Triss Merigold","img":null,"order":7},{"name":"Mahesh Jadu","character":"Vilgefortz of Roggeveen","img":null,"order":8},{"name":"Cassie Clare","character":"Philippa Eilhart","img":null,"order":11},{"name":"MyAnna Buring","character":"Tissaia De Vries","img":null,"order":14},{"name":"Mecia Simson","character":"Francesca Findabair","img":null,"order":18},{"name":"Bart Edwards","character":"The White Flame","img":null,"order":345},{"name":"Wilson Mbomio","character":"Dara","img":null,"order":500},{"name":"Therica Wilson-Read","character":"Sabrina","img":null,"order":542},{"name":"Royce Pierreson","character":"Istredd","img":null,"order":671}],"movie:76203":[{"name":"Chiwetel Ejiofor","character":"Solomon Northup","img":null,"order":0},{"name":"Michael Fassbender","character":"Edwin Epps","img":null,"order":1},{"name":"Lupita Nyong'o","character":"Patsey","img":null,"order":2},{"name":"Benedict Cumberbatch","character":"William Ford","img":null,"order":3},{"name":"Paul Dano","character":"John Tibeats","img":null,"order":4},{"name":"Sarah Paulson","character":"Mary Epps","img":null,"order":5},{"name":"Adepero Oduye","character":"Eliza","img":null,"order":6},{"name":"Scoot McNairy","character":"Brown","img":null,"order":7},{"name":"Paul Giamatti","character":"Theophilus Freeman","img":null,"order":8},{"name":"Brad Pitt","character":"Samuel Bass","img":null,"order":9},{"name":"Michael Kenneth Williams","character":"Robert","img":null,"order":10},{"name":"Alfre Woodard","character":"Mistress Shaw","img":null,"order":11},{"name":"Chris Chalk","character":"Clemens","img":null,"order":12},{"name":"Taran Killam","character":"Hamilton","img":null,"order":13},{"name":"Bill Camp","character":"Radburn","img":null,"order":14}],"movie:13654":[{"name":"Barry Bostwick","character":"Thunderbolt (voice)","img":null,"order":0},{"name":"Jason Alexander","character":"Lightning (voice)","img":null,"order":1},{"name":"Martin Short","character":"Lars (voice)","img":null,"order":2},{"name":"Bobby Lockwood","character":"Patch (voice)","img":null,"order":3},{"name":"Susanne Blakeslee","character":"Cruella (voice)","img":null,"order":4},{"name":"Samuel West","character":"Pongo (voice)","img":null,"order":5},{"name":"Maurice LaMarche","character":"Horace (voice)","img":null,"order":6},{"name":"Jeff Bennett","character":"Jasper (voice)","img":null,"order":7},{"name":"Jodi Benson","character":"Anita (voice)","img":null,"order":8},{"name":"Tim Bentinck","character":"Roger (voice)","img":null,"order":9},{"name":"Kath Soucie","character":"Perdita (voice)","img":null,"order":10},{"name":"Mary MacLeod","character":"Nanny (voice)","img":null,"order":11},{"name":"Michael Lerner","character":"Producer (voice)","img":null,"order":12},{"name":"Kathryn Beaumont","character":"Additional Voices (voice)","img":null,"order":13},{"name":"Nicolette Little","character":"Additional Voices (voice)","img":null,"order":14}],"movie:974453":[{"name":"Liam Neeson","character":"Thug","img":null,"order":0},{"name":"Ron Perlman","character":"Charlie Conner","img":null,"order":1},{"name":"Yolonda Ross","character":"Woman","img":null,"order":2},{"name":"Frankie Shaw","character":"Daisy","img":null,"order":3},{"name":"Daniel Diemer","character":"Kyle Conner","img":null,"order":4},{"name":"Jimmy Gonzales","character":"Diego Machado","img":null,"order":5},{"name":"Josh Drennen","character":"Thug's Dad","img":null,"order":6},{"name":"Deanna Tarraza","character":"Araceli","img":null,"order":7},{"name":"Terrence Pulliam","character":"Dre","img":null,"order":8},{"name":"Omar Moustafa Ghonim","character":"Armando","img":null,"order":9},{"name":"Tom Kemp","character":"Burt","img":null,"order":10},{"name":"Ryan Homchick","character":"Dr. Gruber","img":null,"order":11},{"name":"William Xifaras","character":"Kiko","img":null,"order":12},{"name":"Kris Eivers","character":"Priest","img":null,"order":13},{"name":"Ryan Caraway","character":"George","img":null,"order":14}],"movie:446791":[{"name":"Michelle Williams","character":"Gail Harris","img":null,"order":0},{"name":"Mark Wahlberg","character":"Fletcher Chase","img":null,"order":1},{"name":"Christopher Plummer","character":"Jean Paul Getty","img":null,"order":2},{"name":"Charlie Plummer","character":"John Paul Getty III","img":null,"order":3},{"name":"Romain Duris","character":"Cinquanta","img":null,"order":4},{"name":"Timothy Hutton","character":"Oswald Hinge","img":null,"order":5},{"name":"Charlie Shotwell","character":"John Paul Getty III (Age 7)","img":null,"order":6},{"name":"Andrew Buchan","character":"John Paul Getty II","img":null,"order":7},{"name":"Marco Leonardi","character":"Saverio Mammoliti","img":null,"order":8},{"name":"Giuseppe Bonifati","character":"Giovanni Iacovoni","img":null,"order":9},{"name":"Nicolas Vaporidis","character":"Il Tamia 'Chipmunk'","img":null,"order":10},{"name":"Andrea Piedimonte Bodini","character":"Corvo","img":null,"order":11},{"name":"Guglielmo Favilla","character":"Piccolino","img":null,"order":12},{"name":"Adele Tirante","character":"Maria","img":null,"order":13},{"name":"Stacy Martin","character":"Nancy","img":null,"order":14}],"movie:1013601":[{"name":"Robert De Niro","character":"Vito Genovese / Frank Costello","img":null,"order":0},{"name":"Debra Messing","character":"Bobbie Costello","img":null,"order":1},{"name":"Cosmo Jarvis","character":"Vincent Gigante","img":null,"order":2},{"name":"Kathrine Narducci","character":"Anna Genovese","img":null,"order":3},{"name":"Michael Rispoli","character":"Albert Anastasia","img":null,"order":4},{"name":"Michael Adler","character":"Senator Tobey","img":null,"order":5},{"name":"Ed Amatrudo","character":"Rudolph Halley","img":null,"order":6},{"name":"Joe Bacino","character":"Joe Profaci","img":null,"order":7},{"name":"Anthony J. Gallo","character":"Tommy Lucchese","img":null,"order":8},{"name":"Wallace Langham","character":"Senator Estes Kefauver","img":null,"order":9},{"name":"Louis Mustillo","character":"Joe Bonanno","img":null,"order":10},{"name":"Frank Piccirillo","character":"Richie Boiardo","img":null,"order":11},{"name":"Matt Servitto","character":"George Wolf","img":null,"order":12},{"name":"Robert Uricola","character":"Tony Bender","img":null,"order":13},{"name":"Belmont Cameli","character":"Frankie Boy","img":null,"order":14}],"movie:416153":[{"name":"James Franco","character":"Lemmy (voice)","img":null,"order":0},{"name":"Jeremy Renner","character":"Swifty (voice)","img":null,"order":1},{"name":"Alec Baldwin","character":"PB (voice)","img":null,"order":2},{"name":"Anjelica Huston","character":"Magda (voice)","img":null,"order":3},{"name":"Michael Madsen","character":"Duke (voice)","img":null,"order":4},{"name":"Heidi Klum","character":"Jade / Bertha (voice)","img":null,"order":5},{"name":"John Cleese","character":"Otto von Walrus (voice)","img":null,"order":6},{"name":"Omar Sy","character":"Leopold (voice)","img":null,"order":7},{"name":"Laurie Holden","character":"Dakota (voice)","img":null,"order":8},{"name":"Donny Falsetti","character":"Dusty (voice)","img":null,"order":9},{"name":"Susan Spano","character":"Mary (voice)","img":null,"order":10},{"name":"Jason Deline","character":"Naz Narwahl (voice)","img":null,"order":11},{"name":"Aaron Woodley","character":"Puffin Leader (voice)","img":null,"order":12},{"name":"Lucie Hager","character":"Countdown Inka (voice)","img":null,"order":13},{"name":"Lillian Moloy","character":"Young Jade","img":null,"order":14}],"movie:1607":[{"name":"Robert De Niro","character":"Lorenzo Anello","img":null,"order":0},{"name":"Chazz Palminteri","character":"Sonny LoSpecchio","img":null,"order":1},{"name":"Lillo Brancato","character":"Calogero 'C' Anello (Age 17)","img":null,"order":2},{"name":"Francis Capra","character":"Calogero 'C' Anello (Age 9)","img":null,"order":3},{"name":"Taral Hicks","character":"Jane Williams","img":null,"order":4},{"name":"Kathrine Narducci","character":"Rosina Anello","img":null,"order":5},{"name":"Clem Caserta","character":"Jimmy Whispers","img":null,"order":6},{"name":"Alfred Sauchelli, Jr.","character":"Bobby Bars","img":null,"order":7},{"name":"Frank Pietrangolare","character":"Danny K.O.","img":null,"order":8},{"name":"Joe Pesci","character":"Carmine","img":null,"order":9},{"name":"Robert D'Andrea","character":"Tony Toupee","img":null,"order":10},{"name":"Eddie Montanaro","character":"Eddie Mush","img":null,"order":11},{"name":"Fred Fischer","character":"JoJo 'The Whale'","img":null,"order":12},{"name":"Dave Salerno","character":"Frankie Coffeecake","img":null,"order":13},{"name":"Joseph D'Onofrio","character":"Slick (Age 17)","img":null,"order":14}],"movie:1380417":[{"name":"Yahya Abdul-Mateen II","character":"Wayne Strider","img":null,"order":0},{"name":"Mark Wahlberg","character":"Gregory Scarpa","img":null,"order":1},{"name":"Nicole Beharie","character":"Allison Strider","img":null,"order":2},{"name":"Josh Lucas","character":"Special Agent Everett Rhodes","img":null,"order":3},{"name":"David Strathairn","character":"Special Agent Roy K. Moore","img":null,"order":4},{"name":"Giancarlo Esposito","character":"Vernon Dahmer","img":null,"order":5},{"name":"Ethan Embry","character":"Sam Bowers","img":null,"order":6},{"name":"LisaGay Hamilton","character":"Ms. Rebecca Brewer","img":null,"order":7},{"name":"LaChanze","character":"Hattie Dahmer","img":null,"order":8},{"name":"Biko Eisen-Martin","character":"Special Agent Archie Crawford","img":null,"order":9},{"name":"Will Brittain","character":"Billy Roy Pitts","img":null,"order":10},{"name":"DeVere Jehl","character":"Edgar Ray Killen","img":null,"order":11},{"name":"Jody Thompson","character":"Lawrence Byrd","img":null,"order":12},{"name":"Zach Lane","character":"Howard Styles","img":null,"order":13},{"name":"Lee Perkins","character":"Sheriff Mitchell","img":null,"order":14}],"movie:271110":[{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":0},{"name":"Robert Downey Jr.","character":"Tony Stark / Iron Man","img":null,"order":1},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":2},{"name":"Sebastian Stan","character":"Bucky Barnes / Winter Soldier","img":null,"order":3},{"name":"Anthony Mackie","character":"Sam Wilson / Falcon","img":null,"order":4},{"name":"Don Cheadle","character":"Lieutenant James Rhodes / War Machine","img":null,"order":5},{"name":"Jeremy Renner","character":"Clint Barton / Hawkeye","img":null,"order":6},{"name":"Chadwick Boseman","character":"T'Challa / Black Panther","img":null,"order":7},{"name":"Paul Bettany","character":"Vision","img":null,"order":8},{"name":"Elizabeth Olsen","character":"Wanda Maximoff / Scarlet Witch","img":null,"order":9},{"name":"Paul Rudd","character":"Scott Lang / Ant-Man","img":null,"order":10},{"name":"Emily VanCamp","character":"Sharon Carter","img":null,"order":11},{"name":"Tom Holland","character":"Peter Parker / Spider-Man","img":"xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","order":12},{"name":"Daniel Br\u00fchl","character":"Zemo","img":null,"order":13},{"name":"Frank Grillo","character":"Brock Rumlow / Crossbones","img":null,"order":14}],"movie:1771":[{"name":"Chris Evans","character":"Captain America / Steve Rogers","img":null,"order":0},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":1},{"name":"Sebastian Stan","character":"James Buchanan 'Bucky' Barnes","img":null,"order":2},{"name":"Tommy Lee Jones","character":"Colonel Chester Phillips","img":null,"order":3},{"name":"Hugo Weaving","character":"Johann Schmidt / Red Skull","img":null,"order":4},{"name":"Dominic Cooper","character":"Howard Stark","img":null,"order":5},{"name":"Richard Armitage","character":"Heinz Kruger","img":null,"order":6},{"name":"Stanley Tucci","character":"Dr. Abraham Erskine","img":null,"order":7},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":8},{"name":"Toby Jones","character":"Dr. Arnim Zola","img":null,"order":9},{"name":"Neal McDonough","character":"Timothy 'Dum Dum' Dugan","img":null,"order":10},{"name":"Derek Luke","character":"Gabe Jones","img":null,"order":11},{"name":"Kenneth Choi","character":"Jim Morita","img":null,"order":12},{"name":"JJ Feild","character":"James Montgomery Falsworth","img":null,"order":13},{"name":"Bruno Ricci","character":"Jacques Dernier","img":null,"order":14}],"movie:100402":[{"name":"Chris Evans","character":"Steve Rogers / Captain America","img":null,"order":0},{"name":"Samuel L. Jackson","character":"Nick Fury","img":null,"order":1},{"name":"Scarlett Johansson","character":"Natasha Romanoff / Black Widow","img":null,"order":2},{"name":"Robert Redford","character":"Alexander Pierce","img":null,"order":3},{"name":"Sebastian Stan","character":"Bucky Barnes / Winter Soldier","img":null,"order":4},{"name":"Anthony Mackie","character":"Sam Wilson / Falcon","img":null,"order":5},{"name":"Cobie Smulders","character":"Maria Hill","img":null,"order":6},{"name":"Frank Grillo","character":"Brock Rumlow","img":null,"order":7},{"name":"Maximiliano Hern\u00e1ndez","character":"Jasper Sitwell","img":null,"order":8},{"name":"Emily VanCamp","character":"Kate / Agent 13","img":null,"order":9},{"name":"Hayley Atwell","character":"Peggy Carter","img":null,"order":10},{"name":"Toby Jones","character":"Dr. Arnim Zola","img":null,"order":11},{"name":"Stan Lee","character":"Smithsonian Guard","img":null,"order":12},{"name":"Callan Mulvey","character":"Jack Rollins","img":null,"order":13},{"name":"Jenny Agutter","character":"Councilwoman Hawley","img":null,"order":14}],"tv:60948":[{"name":"Aaron Stanford","character":"James Cole","img":null,"order":0},{"name":"Amanda Schull","character":"Cassandra Railly","img":null,"order":1},{"name":"Kirk Acevedo","character":"Jos\u00e9 Ramse","img":null,"order":2},{"name":"Barbara Sukowa","character":"Katarina Jones","img":null,"order":3},{"name":"Emily Hampshire","character":"Jennifer Goines","img":null,"order":4},{"name":"Todd Stashwick","character":"Deacon","img":null,"order":6},{"name":"Noah Bean","character":"Aaron Marker","img":null,"order":7},{"name":"Alisen Richmond-Peck","character":"Striking Woman","img":null,"order":366},{"name":"Scottie Thompson","character":"Vivian Rutledge","img":null,"order":519},{"name":"Peter DaCunha","character":"Samuel Ramse","img":null,"order":760},{"name":"Murray Furrow","character":"Lasky","img":null,"order":797},{"name":"Andrew Gillies","character":"Julian Adler","img":null,"order":800},{"name":"Tom Noonan","character":"Pallid Man","img":null,"order":805},{"name":"Demore Barnes","character":"Marcus Whitley","img":null,"order":806},{"name":"Brooke Williams","character":"Hannah Jones","img":null,"order":807}],"tv:202097":[{"name":"Jill Hennessy","character":"Lynn Harmon","img":null,"order":501},{"name":"Oakes Fegley","character":"Devin Harmon","img":null,"order":502},{"name":"Robert Wisdom","character":"Mitch Becker","img":null,"order":503},{"name":"J. Harrison Ghee","character":"Kevin Milstead","img":null,"order":504},{"name":"Evan Marsh","character":"Alex Harmon","img":null,"order":505},{"name":"Sonny McKnight","character":"Young Devin (Age 3)","img":null,"order":506},{"name":"Coleby Wojtak-Hissong","character":"Young Devin (Age 5)","img":null,"order":507},{"name":"Athan Giazitzidis","character":"Young Alex","img":null,"order":508},{"name":"Ali Momen","character":"Principal Sam Canosa","img":null,"order":509},{"name":"Tara Rosling","character":"Dr. Laurie Grotstein","img":null,"order":510},{"name":"Deklon Roberts","character":"Captain Kramer","img":null,"order":511},{"name":"Seema Doad","character":"Uniformed Officer","img":null,"order":512},{"name":"Michael Chiklis","character":"Dr. Scott Harmon","img":null,"order":596},{"name":"Daniel Maslany","character":"Public Defender Eli Demille","img":null,"order":633},{"name":"Matthew James Thomas","character":"Alexei","img":null,"order":639}],"tv:79410":[{"name":"Ricky Gervais","character":"Tony","img":null,"order":0},{"name":"Tom Basden","character":"Matt","img":null,"order":1},{"name":"Kerry Godliman","character":"Lisa","img":null,"order":2},{"name":"David Bradley","character":"Ray","img":null,"order":3},{"name":"Joe Wilkinson","character":"Pat","img":null,"order":4},{"name":"Roisin Conaty","character":"Daphne \"Roxy\"","img":null,"order":6},{"name":"Tony Way","character":"Lenny","img":null,"order":7},{"name":"Diane Morgan","character":"Kath","img":null,"order":9},{"name":"Ashley Jensen","character":"Emma","img":null,"order":11},{"name":"Tommy Finnegan","character":"George","img":null,"order":12},{"name":"Jo Hartley","character":"June","img":null,"order":13},{"name":"Penelope Wilton","character":"Anne","img":null,"order":14},{"name":"David Earl","character":"Brian","img":null,"order":15},{"name":"Ethan Lawrence","character":"James","img":null,"order":16},{"name":"Michelle Greenidge","character":"Valerie","img":null,"order":17}],"tv:7406":[{"name":"Lynda Bellingham","character":"Helen Herriot","img":null,"order":0},{"name":"Robert Hardy","character":"Siegfried Farnon","img":null,"order":1},{"name":"Christopher Timothy","character":"James Herriot","img":null,"order":2},{"name":"Carol Drinkwater","character":"Helen Herriot","img":null,"order":3},{"name":"Mary Hignett","character":"Mrs. Hall","img":null,"order":4},{"name":"Margaretta Scott","character":"Mrs. Pumphrey","img":null,"order":7},{"name":"Teddy Turner","character":"Hodgekin","img":null,"order":8},{"name":"Peter Davison","character":"Tristan Farnon","img":null,"order":10},{"name":"John McGlynn","character":"Calum Buchanan","img":null,"order":11},{"name":"Jean Heywood","character":"Mrs Alton","img":null,"order":12},{"name":"Peter Martin","character":"Handshaw","img":null,"order":839},{"name":"Oliver Wilson","character":"Jimmy Herriot","img":null,"order":928},{"name":"Rebecca Smith","character":"Rosie Herriot","img":null,"order":929},{"name":"John Sharp","character":"Mr. Biggins","img":null,"order":952},{"name":"Andrea Gibb","character":"Deidre Buchanan","img":null,"order":971}],"tv:67117":[{"name":"Katy Mixon","character":"Katie Otto","img":null,"order":0},{"name":"Diedrich Bader","character":"Greg Otto","img":null,"order":1},{"name":"Meg Donnelly","character":"Taylor Otto","img":null,"order":2},{"name":"Giselle Eisenberg","character":"Anna-Kat Otto","img":null,"order":3},{"name":"Julia Butters","character":"Anna-Kat Otto","img":null,"order":4},{"name":"Daniel DiMaggio","character":"Oliver Otto","img":null,"order":5},{"name":"Carly Hughes","character":"Angela","img":null,"order":6},{"name":"Ali Wong","character":"Doris","img":null,"order":7},{"name":"Jerry Lambert","character":"Principal Ablin","img":null,"order":515},{"name":"Logan Pepper","character":"Cooper Bradford","img":null,"order":518},{"name":"Peyton Meyer","character":"Trip Windsor","img":null,"order":519},{"name":"Wendie Malick","character":"Kathryn","img":null,"order":532},{"name":"Carly Craig","character":"Tara","img":null,"order":541},{"name":"Evan O'Toole","character":"Franklin","img":null,"order":565},{"name":"Julie Meyer","character":"Maria","img":null,"order":634}],"tv:47054":[{"name":"Mark Addy","character":"Hercules","img":null,"order":0},{"name":"Jack Donnelly","character":"Jason","img":null,"order":1},{"name":"Robert Emms","character":"Pythagoras","img":null,"order":2},{"name":"Aiysha Hart","character":"Ariadne","img":null,"order":3},{"name":"Sarah Parish","character":"Pasiphae","img":null,"order":4},{"name":"Juliet Stevenson","character":"The Oracle","img":null,"order":5},{"name":"Jemima Rooper","character":"Medusa","img":null,"order":6},{"name":"Amy Manson","character":"Medea","img":null,"order":7},{"name":"Alexander Siddig","character":"Minos","img":null,"order":8},{"name":"Vincent Regan","character":"Dion","img":null,"order":9},{"name":"Lorcan Cranitch","character":"Cilix","img":null,"order":10},{"name":"Robert Lindsay","character":"Daedalus","img":null,"order":11},{"name":"Anya Taylor-Joy","character":"Cassandra","img":null,"order":514},{"name":"Stuart Davidson","character":"Lysis's Masseur","img":null,"order":516},{"name":"Kate Davies-Speak","character":"Atlantean","img":null,"order":533}],"tv:62481":[{"name":"Bobby Flay","character":"Self","img":null,"order":0},{"name":"Giada De Laurentiis","character":"Self","img":null,"order":475},{"name":"Michael Symon","character":"Self","img":null,"order":493},{"name":"Scott Conant","character":"Self - Guest Judge","img":null,"order":503},{"name":"Alex Guarnaschelli","character":"Self - Guest Judge","img":null,"order":512},{"name":"Eddie Jackson","character":"Self","img":null,"order":512},{"name":"Sunny Anderson","character":"Self","img":null,"order":515},{"name":"Jet Tila","character":"Self","img":null,"order":524},{"name":"Anne Burrell","character":"Self","img":null,"order":526},{"name":"Ted Allen","character":"Self","img":null,"order":534},{"name":"Simon Majumdar","character":"Self","img":null,"order":536},{"name":"Geoffrey Zakarian","character":"Self","img":null,"order":537},{"name":"Michael Voltaggio","character":"Self","img":null,"order":549},{"name":"Amanda Freitag","character":"Self","img":null,"order":569},{"name":"Katie Lee Biegel","character":"Self","img":null,"order":645}],"tv:1849":[{"name":"Robert Guillaume","character":"Benson DuBois","img":null,"order":0},{"name":"James Noble","character":"Eugene Gatling","img":null,"order":1},{"name":"Inga Swenson","character":"Gretchen Kraus","img":null,"order":2},{"name":"Caroline McWilliams","character":"Marcy Hill","img":null,"order":3},{"name":"Missy Gold","character":"Katie Gatling","img":null,"order":4},{"name":"Ethan Phillips","character":"Pete Downey","img":null,"order":5},{"name":"Didi Conn","character":"Denise Stevens","img":null,"order":6},{"name":"Lewis J. Stadlen","character":"John Taylor","img":null,"order":7},{"name":"Ren\u00e9 Auberjonois","character":"Clayton Endicott III","img":null,"order":8},{"name":"Billie Bird","character":"Rose Cassidy","img":null,"order":9},{"name":"Beverly Todd","character":"Casey Turner","img":null,"order":507},{"name":"Ren\u00e9 Enr\u00edquez","character":"","img":null,"order":509},{"name":"Norman Bartold","character":"","img":null,"order":513},{"name":"G.W. Bailey","character":"","img":null,"order":539},{"name":"Jerry Seinfeld","character":"Frankie","img":null,"order":552}],"tv:249039":[{"name":"Jason Bateman","character":"Vince Friedken","img":null,"order":0},{"name":"Jude Law","character":"Jake Friedken","img":null,"order":1},{"name":"Chris Coy","character":"Babbitt","img":null,"order":2},{"name":"Troy Kotsur","character":"Joe Mancuso","img":null,"order":3},{"name":"Abbey Lee","character":"Anna","img":null,"order":4},{"name":"Odessa Young","character":"Gen","img":null,"order":5},{"name":"Robin de Jes\u00fas","character":"Tony","img":null,"order":6},{"name":"Cleopatra Coleman","character":"Estelle","img":null,"order":500},{"name":"\u1e62\u1ecdp\u1eb9\u0301 D\u00ecr\u00eds\u00f9","character":"Wes","img":null,"order":501},{"name":"Amaka Okafor","character":"Roxie","img":null,"order":502},{"name":"Dagmara Dominczyk","character":"Val","img":null,"order":503},{"name":"Don Harvey","character":"Matt","img":null,"order":504},{"name":"Gus Birney","character":"Mel Whitney","img":null,"order":505},{"name":"Forrest Weber","character":"Junior","img":null,"order":506},{"name":"Michael Cash","character":"Hunter","img":null,"order":510}],"tv:61381":[{"name":"Anthony Anderson","character":"Andre Johnson","img":null,"order":0},{"name":"Tracee Ellis Ross","character":"Rainbow Johnson","img":null,"order":1},{"name":"Yara Shahidi","character":"Zoey Johnson","img":null,"order":2},{"name":"Marcus Scribner","character":"Junior Johnson","img":null,"order":3},{"name":"Marsai Martin","character":"Diane Johnson","img":null,"order":4},{"name":"Miles Brown","character":"Jack Johnson","img":null,"order":5},{"name":"Jenifer Lewis","character":"Ruby Johnson","img":null,"order":6},{"name":"Deon Cole","character":"Charlie Telphy","img":null,"order":7},{"name":"Peter MacKenzie","character":"Leslie Stevens","img":null,"order":8},{"name":"Jeff Meacham","character":"Josh Oppenhol","img":null,"order":9},{"name":"Katlyn Nichol","character":"Olivia Lockhart","img":null,"order":10},{"name":"Laurence Fishburne","character":"Pops Johnson","img":null,"order":500},{"name":"Nelson Franklin","character":"Connor Stevens","img":null,"order":534},{"name":"Berlin Gross","character":"Devante Johnson","img":null,"order":562},{"name":"August Gross","character":"Devante Johnson","img":null,"order":563}],"tv:1396":[{"name":"Bryan Cranston","character":"Walter White","img":null,"order":0},{"name":"Aaron Paul","character":"Jesse Pinkman","img":null,"order":1},{"name":"Anna Gunn","character":"Skyler White","img":null,"order":2},{"name":"RJ Mitte","character":"Walter White Jr.","img":null,"order":3},{"name":"Dean Norris","character":"Hank Schrader","img":null,"order":4},{"name":"Betsy Brandt","character":"Marie Schrader","img":null,"order":5},{"name":"Giancarlo Esposito","character":"Gustavo Fring","img":null,"order":6},{"name":"Bob Odenkirk","character":"Saul Goodman","img":null,"order":7},{"name":"Steven Michael Quezada","character":"Steven Gomez","img":null,"order":8},{"name":"Jonathan Banks","character":"Mike Ehrmantraut","img":null,"order":9},{"name":"Christopher Cousins","character":"Ted Beneke","img":null,"order":604},{"name":"Michael Shamus Wiles","character":"ASAC George Merkert","img":null,"order":606},{"name":"Lavell Crawford","character":"Huell","img":null,"order":637},{"name":"Charles Baker","character":"Skinny Pete","img":null,"order":656},{"name":"Matt Jones","character":"Badger","img":null,"order":658}],"tv:93166":[{"name":"Holliday Grainger","character":"DI Rachel Carey","img":null,"order":0},{"name":"Callum Turner","character":"Shaun Emery","img":null,"order":1},{"name":"Ben Miles","character":"Commander Danny Hart","img":null,"order":2},{"name":"Lia Williams","character":"DSU Gemma Garland","img":null,"order":3},{"name":"Ron Perlman","character":"Frank Napier","img":null,"order":4},{"name":"Laura Haddock","character":"Hannah Roberts","img":null,"order":5},{"name":"Barry Ward","character":"Charlie Hall","img":null,"order":6},{"name":"Ginny Holder","character":"DC / DI Nadia Latif","img":null,"order":7},{"name":"Cavan Clerkin","character":"DS Patrick Flynn","img":null,"order":8},{"name":"Indira Varma","character":"Khadija Khan","img":null,"order":10},{"name":"Tessa Wong","character":"DC Chloe Tan","img":null,"order":12},{"name":"Andy Nyman","character":"Rowan Gill","img":null,"order":13},{"name":"Daisy Waterstone","character":"Abigail Carey","img":null,"order":15},{"name":"Nigel Lindsay","character":"DSI / DSU Tom Kendricks","img":null,"order":106},{"name":"Paapa Essiedu","character":"Isaac Turner","img":null,"order":160}]},"availableArt":["assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg","assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg","assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg","assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg","assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg","assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg","assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg","assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg","assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg","assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg","assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg","assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg","assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg","assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg","assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg","assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg","assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg","assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg","assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg","assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg","assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg","assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg","assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg","assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg","assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg","assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg","assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg","assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg","assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg","assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg","assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg","assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg","assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg","assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg","assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg","assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg","assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg","assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg","assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg","assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg","assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg","assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg","assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg","assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg","assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg","assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg","assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg","assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg","assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg","assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg","assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg","assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg","assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg","assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg","assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg","assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg","assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg","assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg","assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg","assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg","assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg","assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg","assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg","assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg","assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg","assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg","assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg","assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg","assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg","assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg","assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg","assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg","assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg","assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg","assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg","assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg","assets/episode-announcement-2022-s1e7-backdrop.jpg","assets/episode-announcement-2022-s1e7-poster.jpg","assets/episode-episode-1-2025-s1e1-backdrop.jpg","assets/episode-episode-1-2025-s1e1-poster.jpg","assets/episode-episode-2-2016-s1e2-backdrop.jpg","assets/episode-episode-2-2016-s1e2-poster.jpg","assets/episode-erickerhead-2026-s9e6-backdrop.jpg","assets/episode-erickerhead-2026-s9e6-poster.jpg","assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg","assets/episode-girls-interrupted-2007-s1e7-poster.jpg","assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg","assets/episode-i-heart-ny-2002-s4e18-poster.jpg","assets/episode-new-day-2021-s1e2-backdrop.jpg","assets/episode-new-day-2021-s1e2-poster.jpg","assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg","assets/episode-no-honeymoon-2020-s1e8-poster.jpg","assets/episode-notapusy-2005-s3e4-backdrop.jpg","assets/episode-notapusy-2005-s3e4-poster.jpg","assets/episode-one-of-us-2024-s1e7-backdrop.jpg","assets/episode-one-of-us-2024-s1e7-poster.jpg","assets/episode-remembrance-day-2026-s2e1-backdrop.jpg","assets/episode-remembrance-day-2026-s2e1-poster.jpg","assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg","assets/episode-sex-and-the-country-2001-s4e9-poster.jpg","assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg","assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg","assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg","assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg","assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg","assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg","assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg","assets/episode-the-uncomfortability-2010-s1e2-poster.jpg","assets/episode-three-monsters-1992-s1e6-backdrop.jpg","assets/episode-three-monsters-1992-s1e6-poster.jpg","assets/episode-tomorrow-2024-s3e1-backdrop.jpg","assets/episode-tomorrow-2024-s3e1-poster.jpg","assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg","assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg","assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","assets/movie-a-bronx-tale-1993-backdrop.jpg","assets/movie-a-bronx-tale-1993-poster.jpg","assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","assets/movie-a-silent-voice-the-movie-2016-poster.jpg","assets/movie-absolution-2024-backdrop.jpg","assets/movie-absolution-2024-poster.jpg","assets/movie-across-the-hall-2009-backdrop.jpg","assets/movie-across-the-hall-2009-poster.jpg","assets/movie-addicted-2014-backdrop.jpg","assets/movie-addicted-2014-poster.jpg","assets/movie-adulterers-2016-backdrop.jpg","assets/movie-adulterers-2016-poster.jpg","assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","assets/movie-all-the-money-in-the-world-2017-poster.jpg","assets/movie-alone-with-her-2006-backdrop.jpg","assets/movie-alone-with-her-2006-poster.jpg","assets/movie-american-gangster-2007-backdrop.jpg","assets/movie-american-gangster-2007-poster.jpg","assets/movie-annie-hall-1977-backdrop.jpg","assets/movie-annie-hall-1977-poster.jpg","assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","assets/movie-appleseed-alpha-2014-backdrop.jpg","assets/movie-appleseed-alpha-2014-poster.jpg","assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","assets/movie-arctic-dogs-2019-backdrop.jpg","assets/movie-arctic-dogs-2019-poster.jpg","assets/movie-arrival-2016-backdrop.jpg","assets/movie-arrival-2016-poster.jpg","assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","assets/movie-avengers-age-of-ultron-2015-poster.jpg","assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","assets/movie-avengers-infinity-war-2018-backdrop.jpg","assets/movie-avengers-infinity-war-2018-poster.jpg","assets/movie-awake-2007-backdrop.jpg","assets/movie-awake-2007-poster.jpg","assets/movie-batman-ninja-2018-backdrop.jpg","assets/movie-batman-ninja-2018-poster.jpg","assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","assets/movie-black-adam-2022-backdrop.jpg","assets/movie-black-adam-2022-poster.jpg","assets/movie-black-bear-2020-backdrop.jpg","assets/movie-black-bear-2020-poster.jpg","assets/movie-black-panther-2018-backdrop.jpg","assets/movie-black-panther-2018-poster.jpg","assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","assets/movie-black-panther-wakanda-forever-2022-poster.jpg","assets/movie-blame-2017-backdrop.jpg","assets/movie-blame-2017-poster.jpg","assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","assets/movie-butterfly-on-a-wheel-2007-poster.jpg","assets/movie-captain-america-civil-war-2016-backdrop.jpg","assets/movie-captain-america-civil-war-2016-poster.jpg","assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","assets/movie-careful-what-you-wish-for-2015-poster.jpg","assets/movie-castle-in-the-sky-1986-backdrop.jpg","assets/movie-castle-in-the-sky-1986-poster.jpg","assets/movie-chicago-2002-backdrop.jpg","assets/movie-chicago-2002-poster.jpg","assets/movie-citizen-kane-1941-backdrop.jpg","assets/movie-citizen-kane-1941-poster.jpg","assets/movie-conclave-2024-backdrop.jpg","assets/movie-conclave-2024-poster.jpg","assets/movie-crime-of-passion-1956-backdrop.jpg","assets/movie-crime-of-passion-1956-poster.jpg","assets/movie-deep-water-2022-backdrop.jpg","assets/movie-deep-water-2022-poster.jpg","assets/movie-derailed-2005-backdrop.jpg","assets/movie-derailed-2005-poster.jpg","assets/movie-dream-house-2011-backdrop.jpg","assets/movie-dream-house-2011-poster.jpg","assets/movie-dream-lover-1994-backdrop.jpg","assets/movie-dream-lover-1994-poster.jpg","assets/movie-drop-2025-backdrop.jpg","assets/movie-drop-2025-poster.jpg","assets/movie-eastern-promises-2007-backdrop.jpg","assets/movie-eastern-promises-2007-poster.jpg","assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","assets/movie-fatale-2020-backdrop.jpg","assets/movie-fatale-2020-poster.jpg","assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","assets/movie-ghost-in-the-shell-1995-backdrop.jpg","assets/movie-ghost-in-the-shell-1995-poster.jpg","assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","assets/movie-gosford-park-2001-backdrop.jpg","assets/movie-gosford-park-2001-poster.jpg","assets/movie-howl-s-moving-castle-2004-backdrop.jpg","assets/movie-howl-s-moving-castle-2004-poster.jpg","assets/movie-in-the-heights-2021-backdrop.jpg","assets/movie-in-the-heights-2021-poster.jpg","assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","assets/movie-kiki-s-delivery-service-1989-poster.jpg","assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","assets/movie-locke-2014-backdrop.jpg","assets/movie-locke-2014-poster.jpg","assets/movie-memories-1995-backdrop.jpg","assets/movie-memories-1995-poster.jpg","assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","assets/movie-panic-room-2002-backdrop.jpg","assets/movie-panic-room-2002-poster.jpg","assets/movie-ponyo-2008-backdrop.jpg","assets/movie-ponyo-2008-poster.jpg","assets/movie-porco-rosso-1992-backdrop.jpg","assets/movie-porco-rosso-1992-poster.jpg","assets/movie-princess-mononoke-1997-backdrop.jpg","assets/movie-princess-mononoke-1997-poster.jpg","assets/movie-prisoners-2013-backdrop.jpg","assets/movie-prisoners-2013-poster.jpg","assets/movie-racing-stripes-2005-backdrop.jpg","assets/movie-racing-stripes-2005-poster.jpg","assets/movie-secondhand-lions-2003-backdrop.jpg","assets/movie-secondhand-lions-2003-poster.jpg","assets/movie-shaft-2000-backdrop.jpg","assets/movie-shaft-2000-poster.jpg","assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","assets/movie-sleeping-with-other-people-2015-backdrop.jpg","assets/movie-sleeping-with-other-people-2015-poster.jpg","assets/movie-snowpiercer-2013-backdrop.jpg","assets/movie-snowpiercer-2013-poster.jpg","assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","assets/movie-spider-man-brand-new-day-2026-poster.jpg","assets/movie-spider-man-far-from-home-2019-backdrop.jpg","assets/movie-spider-man-far-from-home-2019-poster.jpg","assets/movie-spider-man-homecoming-2017-backdrop.jpg","assets/movie-spider-man-homecoming-2017-poster.jpg","assets/movie-spider-man-no-way-home-2021-backdrop.jpg","assets/movie-spider-man-no-way-home-2021-poster.jpg","assets/movie-spirited-away-2001-backdrop.jpg","assets/movie-spirited-away-2001-poster.jpg","assets/movie-the-alto-knights-2025-backdrop.jpg","assets/movie-the-alto-knights-2025-poster.jpg","assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","assets/movie-the-amazing-spider-man-2-2014-poster.jpg","assets/movie-the-amazing-spider-man-2012-backdrop.jpg","assets/movie-the-amazing-spider-man-2012-poster.jpg","assets/movie-the-animatrix-2003-backdrop.jpg","assets/movie-the-animatrix-2003-poster.jpg","assets/movie-the-avengers-2012-backdrop.jpg","assets/movie-the-avengers-2012-poster.jpg","assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","assets/movie-the-boy-and-the-heron-2023-poster.jpg","assets/movie-the-breakfast-club-1985-backdrop.jpg","assets/movie-the-breakfast-club-1985-poster.jpg","assets/movie-the-dry-2021-backdrop.jpg","assets/movie-the-dry-2021-poster.jpg","assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","assets/movie-the-godfather-1972-backdrop.jpg","assets/movie-the-godfather-1972-poster.jpg","assets/movie-the-godfather-part-iii-1990-backdrop.jpg","assets/movie-the-godfather-part-iii-1990-poster.jpg","assets/movie-the-great-gatsby-2013-backdrop.jpg","assets/movie-the-great-gatsby-2013-poster.jpg","assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","assets/movie-the-lighthouse-2019-backdrop.jpg","assets/movie-the-lighthouse-2019-poster.jpg","assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","assets/movie-the-wild-robot-2024-backdrop.jpg","assets/movie-the-wild-robot-2024-poster.jpg","assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","assets/movie-thelma-louise-1991-backdrop.jpg","assets/movie-thelma-louise-1991-poster.jpg","assets/movie-triangle-of-sadness-2022-backdrop.jpg","assets/movie-triangle-of-sadness-2022-poster.jpg","assets/movie-vice-2018-backdrop.jpg","assets/movie-vice-2018-poster.jpg","assets/tv-12-monkeys-2015-backdrop.jpg","assets/tv-12-monkeys-2015-poster.jpg","assets/tv-56-days-2026-backdrop.jpg","assets/tv-56-days-2026-poster.jpg","assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","assets/tv-absolutely-fabulous-1992-backdrop.jpg","assets/tv-absolutely-fabulous-1992-poster.jpg","assets/tv-accel-world-2012-backdrop.jpg","assets/tv-accel-world-2012-poster.jpg","assets/tv-accused-2023-backdrop.jpg","assets/tv-accused-2023-poster.jpg","assets/tv-after-life-2019-backdrop.jpg","assets/tv-after-life-2019-poster.jpg","assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","assets/tv-agatha-christie-s-marple-2004-poster.jpg","assets/tv-ahsoka-2023-backdrop.jpg","assets/tv-ahsoka-2023-poster.jpg","assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","assets/tv-all-creatures-great-and-small-1978-poster.jpg","assets/tv-all-creatures-great-small-2020-backdrop.jpg","assets/tv-all-creatures-great-small-2020-poster.jpg","assets/tv-american-dad-2005-backdrop.jpg","assets/tv-american-dad-2005-poster.jpg","assets/tv-american-gods-2017-backdrop.jpg","assets/tv-american-gods-2017-poster.jpg","assets/tv-american-housewife-2016-backdrop.jpg","assets/tv-american-housewife-2016-poster.jpg","assets/tv-american-primeval-2025-backdrop.jpg","assets/tv-american-primeval-2025-poster.jpg","assets/tv-and-just-like-that-2021-backdrop.jpg","assets/tv-and-just-like-that-2021-poster.jpg","assets/tv-andor-2022-backdrop.jpg","assets/tv-andor-2022-poster.jpg","assets/tv-andromeda-2000-backdrop.jpg","assets/tv-andromeda-2000-poster.jpg","assets/tv-anne-with-an-e-2017-backdrop.jpg","assets/tv-anne-with-an-e-2017-poster.jpg","assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","assets/tv-aqua-teen-hunger-force-2000-poster.jpg","assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","assets/tv-arrow-2012-backdrop.jpg","assets/tv-arrow-2012-poster.jpg","assets/tv-atlantis-2013-backdrop.jpg","assets/tv-atlantis-2013-poster.jpg","assets/tv-avatar-seven-havens-2026-backdrop.jpg","assets/tv-avatar-seven-havens-2026-poster.jpg","assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","assets/tv-avatar-the-last-airbender-2005-poster.jpg","assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","assets/tv-avatar-the-last-airbender-2024-poster.jpg","assets/tv-awkward-2011-backdrop.jpg","assets/tv-awkward-2011-poster.jpg","assets/tv-back-to-life-2019-backdrop.jpg","assets/tv-back-to-life-2019-poster.jpg","assets/tv-ballers-2015-backdrop.jpg","assets/tv-ballers-2015-poster.jpg","assets/tv-baretta-1975-backdrop.jpg","assets/tv-baretta-1975-poster.jpg","assets/tv-based-on-a-true-story-2023-backdrop.jpg","assets/tv-based-on-a-true-story-2023-poster.jpg","assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","assets/tv-batwoman-2019-backdrop.jpg","assets/tv-batwoman-2019-poster.jpg","assets/tv-big-boys-2022-backdrop.jpg","assets/tv-big-boys-2022-poster.jpg","assets/tv-big-city-greens-2018-backdrop.jpg","assets/tv-big-city-greens-2018-poster.jpg","assets/tv-big-mouth-2017-backdrop.jpg","assets/tv-big-mouth-2017-poster.jpg","assets/tv-black-lightning-2018-backdrop.jpg","assets/tv-black-lightning-2018-poster.jpg","assets/tv-black-rabbit-2025-backdrop.jpg","assets/tv-black-rabbit-2025-poster.jpg","assets/tv-bleach-2004-backdrop.jpg","assets/tv-bleach-2004-poster.jpg","assets/tv-brassic-2019-backdrop.jpg","assets/tv-brassic-2019-poster.jpg","assets/tv-breaking-bad-2008-backdrop.jpg","assets/tv-breaking-bad-2008-poster.jpg","assets/tv-californication-2007-backdrop.jpg","assets/tv-californication-2007-poster.jpg","assets/tv-chainsaw-man-2022-backdrop.jpg","assets/tv-chainsaw-man-2022-poster.jpg","assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","assets/tv-cyberpunk-edgerunners-2022-poster.jpg","assets/tv-derry-girls-2018-backdrop.jpg","assets/tv-derry-girls-2018-poster.jpg","assets/tv-desperate-housewives-2004-backdrop.jpg","assets/tv-desperate-housewives-2004-poster.jpg","assets/tv-dune-prophecy-2024-backdrop.jpg","assets/tv-dune-prophecy-2024-poster.jpg","assets/tv-etoile-2025-backdrop.jpg","assets/tv-etoile-2025-poster.jpg","assets/tv-fallout-2024-backdrop.jpg","assets/tv-fallout-2024-poster.jpg","assets/tv-firefly-2002-backdrop.jpg","assets/tv-firefly-2002-poster.jpg","assets/tv-fleabag-2016-backdrop.jpg","assets/tv-fleabag-2016-poster.jpg","assets/tv-gilmore-girls-2000-backdrop.jpg","assets/tv-gilmore-girls-2000-poster.jpg","assets/tv-high-potential-2024-backdrop.jpg","assets/tv-high-potential-2024-poster.jpg","assets/tv-invincible-2021-backdrop.jpg","assets/tv-invincible-2021-poster.jpg","assets/tv-last-samurai-standing-2025-backdrop.jpg","assets/tv-last-samurai-standing-2025-poster.jpg","assets/tv-lord-of-the-flies-2026-backdrop.jpg","assets/tv-lord-of-the-flies-2026-poster.jpg","assets/tv-love-death-robots-2019-backdrop.jpg","assets/tv-love-death-robots-2019-poster.jpg","assets/tv-maniac-2018-backdrop.jpg","assets/tv-maniac-2018-poster.jpg","assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","assets/tv-neon-genesis-evangelion-1995-poster.jpg","assets/tv-norsemen-2016-backdrop.jpg","assets/tv-norsemen-2016-poster.jpg","assets/tv-one-piece-2023-backdrop.jpg","assets/tv-one-piece-2023-poster.jpg","assets/tv-only-murders-in-the-building-2021-backdrop.jpg","assets/tv-only-murders-in-the-building-2021-poster.jpg","assets/tv-over-the-garden-wall-2014-backdrop.jpg","assets/tv-over-the-garden-wall-2014-poster.jpg","assets/tv-pluribus-2025-backdrop.jpg","assets/tv-pluribus-2025-poster.jpg","assets/tv-rick-and-morty-2013-backdrop.jpg","assets/tv-rick-and-morty-2013-poster.jpg","assets/tv-secret-level-2024-backdrop.jpg","assets/tv-secret-level-2024-poster.jpg","assets/tv-severance-2022-backdrop.jpg","assets/tv-severance-2022-poster.jpg","assets/tv-sex-and-the-city-1998-backdrop.jpg","assets/tv-sex-and-the-city-1998-poster.jpg","assets/tv-the-100-2014-backdrop.jpg","assets/tv-the-100-2014-poster.jpg","assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","assets/tv-the-abbott-and-costello-show-1952-poster.jpg","assets/tv-the-afterparty-2022-backdrop.jpg","assets/tv-the-afterparty-2022-poster.jpg","assets/tv-the-bear-2022-backdrop.jpg","assets/tv-the-bear-2022-poster.jpg","assets/tv-the-beast-in-me-2025-backdrop.jpg","assets/tv-the-beast-in-me-2025-poster.jpg","assets/tv-the-bold-type-2017-backdrop.jpg","assets/tv-the-bold-type-2017-poster.jpg","assets/tv-the-boys-2019-backdrop.jpg","assets/tv-the-boys-2019-poster.jpg","assets/tv-the-capture-2019-backdrop.jpg","assets/tv-the-capture-2019-poster.jpg","assets/tv-the-durrells-2016-backdrop.jpg","assets/tv-the-durrells-2016-poster.jpg","assets/tv-the-final-problem-2026-backdrop.jpg","assets/tv-the-final-problem-2026-poster.jpg","assets/tv-the-legend-of-korra-2012-backdrop.jpg","assets/tv-the-legend-of-korra-2012-poster.jpg","assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","assets/tv-the-night-manager-2016-backdrop.jpg","assets/tv-the-night-manager-2016-poster.jpg","assets/tv-the-tatami-galaxy-2010-backdrop.jpg","assets/tv-the-tatami-galaxy-2010-poster.jpg","assets/tv-the-white-lotus-2021-backdrop.jpg","assets/tv-the-white-lotus-2021-poster.jpg","assets/tv-the-witcher-2019-backdrop.jpg","assets/tv-the-witcher-2019-poster.jpg","assets/tv-upload-2020-backdrop.jpg","assets/tv-upload-2020-poster.jpg","assets/tv-widow-s-bay-2026-backdrop.jpg","assets/tv-widow-s-bay-2026-poster.jpg","assets/tv-yellowjackets-2021-backdrop.jpg","assets/tv-yellowjackets-2021-poster.jpg"]} diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg deleted file mode 100644 index 12a79fbe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/26R1LBiSOkkP08saSsz6LmVTY16.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg deleted file mode 100644 index 51c11420..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/2tFblvjBRUYwcVT1V1i79Ppd6wG.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg deleted file mode 100644 index 0c5b151f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/320qW5yEbxpmyxQ3evmClJbtKag.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg deleted file mode 100644 index a639becf..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/4F8XpjyQCvtuu21WFm5d8RF5Rl.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg deleted file mode 100644 index f0a4e83a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/4eOoW1PNBZfxRW0g8RmYH8bL6mq.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg deleted file mode 100644 index b28de566..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/4gyHyg6FJ1oFczOm5pmMkdEEo2J.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg deleted file mode 100644 index 35a785df..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/5L0kzTVITmOQdQ94NhicKM8ecCX.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg deleted file mode 100644 index 41618a2e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/5VbadQsYCKdiJysP01LJFPmYB8A.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg deleted file mode 100644 index 5c6e94de..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/5eIRHUbNOH7ibXa0IfUfFvj5I5L.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg deleted file mode 100644 index 1b59f62f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/5hn7WeXi6wguwxTPTSmcmzHAs9.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg deleted file mode 100644 index 21dc4e89..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/6hmUs5Q1EETapn32yKNt3c3i1sn.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg deleted file mode 100644 index dde817a1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/7LVYeFTgUpjYDrxnU7Wm5ZQUX9t.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg deleted file mode 100644 index 5094cb73..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/7MHdNxS1gRnx0ZQGPrfS5eRjMe3.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg deleted file mode 100644 index 1f88cd10..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/7Y96dAfg0HcFrcLjlD5eD9N0uj4.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg deleted file mode 100644 index 5d5930b7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/8v3lDa0QCJlDtOx9flqlMNIA3uf.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg deleted file mode 100644 index 6a483975..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/9pxaoasotR1pdCXYSfN1pkm0geO.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg deleted file mode 100644 index a1df3481..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/aHQk6XxqyJinfvrVRADEIg31nOk.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg deleted file mode 100644 index 50197dbe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/aW6vCxkUZtwb6iH2Wf88Uq0XNVv.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg deleted file mode 100644 index 5aa08a58..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/bLgmpSFgEiSFNshqmdl3EIZkRZm.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg deleted file mode 100644 index 07813572..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/biov5jENp2XYfehkcdnRvi796Wi.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg deleted file mode 100644 index 77d5fe66..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/d81K0RH8UX7tZj49tZaQhZ9ewH.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg deleted file mode 100644 index 3c66e9a4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/fD8GuLHOOSSAG98VjwN3NhAmqAJ.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg deleted file mode 100644 index 15a25098..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/fbWWkhe7GVSlhBljY89rJd01OnA.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg deleted file mode 100644 index 60531535..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/fdFVatb9pt5dfT9tDK8tikKWafS.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg deleted file mode 100644 index 989ce70d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/fjJc03z1cOlMxLZbC1qihsq3C37.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg deleted file mode 100644 index 783d7043..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/hOntuFYAMwn9QaXT0AwLCPU79ce.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg deleted file mode 100644 index 5f9dc239..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/hrTwOPWek7nkPJgFhh6inFu7vSS.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg deleted file mode 100644 index 2b3d2015..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/hzu7OqTmNCT1xdCdmwBSf2yJvaO.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg deleted file mode 100644 index 5965e06d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/iQLEawIP2593yrGy22GzLSpnfgl.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg deleted file mode 100644 index 0921d96c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/j0GXzLelO1dOzI7d1WVIwQXhLz8.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg deleted file mode 100644 index 7e03f82b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/jUuUbPuMGonFT5E2pcs4alfqaCN.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg deleted file mode 100644 index 01f41d25..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/lF06PTNYrlXeJ3DYApvOcPXJr87.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg deleted file mode 100644 index 8baff3f2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/lUhVV9u5VGMCNdCAVhGzg8GFSdo.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg deleted file mode 100644 index 11d18b7a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/lawJUdbLb1YW0niaYjiVGR9IBG8.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg deleted file mode 100644 index 64a37106..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/lfbyJWNWGbiYfU0Yr1tjn6Trd2V.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg deleted file mode 100644 index 5ff2d6a1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/lpEs9yDsMQlsbm0RVsvDIleCXcI.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg deleted file mode 100644 index 2a4445ce..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/mF5YJxApTYKBVFJDrEC9IrJ5ooV.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg deleted file mode 100644 index 21dd5ffe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/mW7xmtGV4y79kQGn0zkKVGDMAmw.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg deleted file mode 100644 index b4189cd0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/mk5vpYLmER7asqOhEwVKXD3P3YE.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg deleted file mode 100644 index 40aee939..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/mnKYU7HIsuRkx3YjdRO5FLXCdX5.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg deleted file mode 100644 index afc0e401..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/msqe5A1q5YUUND0RhzB9dw1AEAO.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg deleted file mode 100644 index 2e179e76..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/nAxS5l9vb1C8ltCp9yAa0f6lazu.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg deleted file mode 100644 index 5a261d60..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/nmgOd3X2Xn3jIp9OLCRJzLExRWN.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg deleted file mode 100644 index 4cdadf08..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/o7splcOIVAdbHnwohJOUAvXWVlk.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg deleted file mode 100644 index 99b5bcd6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/oMHMLhoV3ry8aOfdvs1kxyowhNq.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg deleted file mode 100644 index 1ee336bd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/omWXGbeRR32MWuYhpnLOSqJ6xkO.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg deleted file mode 100644 index 4dbe126b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/pVwR9hsEiHzVTo9giCjR3vIfa8W.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg deleted file mode 100644 index b673a5b2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/phEIvq0Ay2KjI5ewmVvpQ1a29GJ.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg deleted file mode 100644 index ed34c316..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/qP5vQMIbLNvSsSFkEMLZRVt4ZW5.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg deleted file mode 100644 index e5fd3f84..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/qiWu2gphSg64igYl3Nx0hEIOQF3.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg deleted file mode 100644 index 4132aac4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/qwbjcyDN37OHiln4a6P4LP31o7F.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg deleted file mode 100644 index 8ae18ac0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/r6s7dkYZUJpd00C7p9itT6Gp8XB.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg deleted file mode 100644 index 8d6c9a83..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/rDTxblyw0AwU5oSeksgP7GwMyrF.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg deleted file mode 100644 index 0b985c84..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/rIqcwewXdkrDTMTwX56QPa7MNTC.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg deleted file mode 100644 index 7377c541..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/rLzduQpYJnZJKkXv3jRc7MDHwoI.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg deleted file mode 100644 index f23bdc49..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/rbxBK2m6fEq0oHeMoBUMVYkJEdJ.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg deleted file mode 100644 index 755a5276..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/sG6CWLdENCtzukNFkFYyPxqyRmS.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg deleted file mode 100644 index c1a9d397..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/sh4ANkKoIHgmHtkKQbJvF8y0Fjq.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg deleted file mode 100644 index 5b2de159..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/tqFHQiJUycuob3ytvilaAWDlbQ8.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg deleted file mode 100644 index 4f1e2f21..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/uL8yfcm0dCFYFQt052kczVQVvac.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg deleted file mode 100644 index 725372ad..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/ub2IuMWFNQGYghHTPq0lpmn2Ue0.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg deleted file mode 100644 index 7ed17793..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/ukDNHgvEgdU95eaw6pngiAPucv4.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg deleted file mode 100644 index 2118c505..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/v4sRxLpRpHEd9vbk43wm9VL0v0K.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg deleted file mode 100644 index 3a609d1e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/wWcby4ARYqr8d92ZdjyQcfaiJBW.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg deleted file mode 100644 index b650362b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/waayy0jpNbxLDQ8JO1uRDng8SLF.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg deleted file mode 100644 index 3d3f33ee..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/x0lmqgkmphvOCSTHk2oxsaxA9Ra.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg deleted file mode 100644 index fba488ba..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/xKBAaPIa1c7tzZD3Y0MhBLv4hPE.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg deleted file mode 100644 index eb015896..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/xV9UrP8kZdxOiTI2efDFhp3t4iA.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg deleted file mode 100644 index bc482f04..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/xm2pYFBggiXSkFBV2I612i3fDd3.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg deleted file mode 100644 index dd70f23a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/xx3As5SWcE8vYOKZgtjDjqmT3jc.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg deleted file mode 100644 index dd0796e7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/yU0OXeZ0K1ppe1o9SpHiAMHy1rc.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg deleted file mode 100644 index 04d476bb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/yWZILsr2mBQBCG1LOcCYYshIbts.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg deleted file mode 100644 index 6e7a492b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/yxGsAKnrJZs2g6q4unNgkfm7NAA.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg b/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg deleted file mode 100644 index 6883cc09..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/cast/ztxDIZvuPreUixO6ZPxwkCg2UlO.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg deleted file mode 100644 index f5b2f94c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-announcement-2022-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg deleted file mode 100644 index f2519b04..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg deleted file mode 100644 index 77febd79..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-1-2025-s1e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-episode-2-2016-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-erickerhead-2026-s9e6-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-girls-interrupted-2007-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-i-heart-ny-2002-s4e18-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-new-day-2021-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg deleted file mode 100644 index 9d95ba77..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-no-honeymoon-2020-s1e8-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg deleted file mode 100644 index ecb7e8cd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-notapusy-2005-s3e4-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-one-of-us-2024-s1e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg deleted file mode 100644 index 23a242da..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-remembrance-day-2026-s2e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg deleted file mode 100644 index cd0aec39..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg deleted file mode 100644 index 84944d39..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg deleted file mode 100644 index bc22e11a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg deleted file mode 100644 index ba65ca2d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-three-monsters-1992-s1e6-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-tomorrow-2024-s3e1-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg deleted file mode 100644 index 13f0f7b3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg deleted file mode 100644 index f0d6f752..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg deleted file mode 100644 index b9b72894..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg deleted file mode 100644 index 15c11ff7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg deleted file mode 100644 index f7444271..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-a-bronx-tale-1993-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg deleted file mode 100644 index b386b136..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg deleted file mode 100644 index 580b22f4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-a-silent-voice-the-movie-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg deleted file mode 100644 index eb28037c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg deleted file mode 100644 index 3ce49112..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-absolution-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg deleted file mode 100644 index 6c1ee8ac..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg deleted file mode 100644 index 52362b4e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-across-the-hall-2009-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg deleted file mode 100644 index 5c016976..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg deleted file mode 100644 index e9a03c14..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-addicted-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg deleted file mode 100644 index 331391ed..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg deleted file mode 100644 index 8b65745b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-adulterers-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg deleted file mode 100644 index ac4b828a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg deleted file mode 100644 index 8beb2f7f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-all-the-money-in-the-world-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg deleted file mode 100644 index ddf8f1dc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg deleted file mode 100644 index 7ce6fa96..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-alone-with-her-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg deleted file mode 100644 index cb5c5de7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg deleted file mode 100644 index 0f41d299..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-american-gangster-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg deleted file mode 100644 index 16d0b27d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg deleted file mode 100644 index 9023a691..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-annie-hall-1977-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg deleted file mode 100644 index 2a99846d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg deleted file mode 100644 index 030addf0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg deleted file mode 100644 index 82579b86..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg deleted file mode 100644 index 62bd0afa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-appleseed-alpha-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg deleted file mode 100644 index 6693cb6d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg deleted file mode 100644 index 4550bdaa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg deleted file mode 100644 index 1794c99d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg deleted file mode 100644 index 605724e7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg deleted file mode 100644 index fe224a15..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-arrival-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg deleted file mode 100644 index d3b12fe0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg deleted file mode 100644 index f57f1052..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg deleted file mode 100644 index f739d620..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg deleted file mode 100644 index 8163931a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg deleted file mode 100644 index b096fff2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg deleted file mode 100644 index 90dc4672..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg deleted file mode 100644 index 57df9298..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-avengers-infinity-war-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg deleted file mode 100644 index 6ddd7f59..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg deleted file mode 100644 index 9b538d9f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg deleted file mode 100644 index 7f59127b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-ninja-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg deleted file mode 100644 index 9d385801..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg deleted file mode 100644 index ce5623fe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg deleted file mode 100644 index b4fa63b9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg deleted file mode 100644 index f8115170..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg deleted file mode 100644 index 3232fb22..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg deleted file mode 100644 index dde008b5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-adam-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg deleted file mode 100644 index 4b0b262f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg deleted file mode 100644 index 61c8860f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-bear-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg deleted file mode 100644 index f0b8de8a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg deleted file mode 100644 index 6fdaa3d2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg deleted file mode 100644 index ac7ad532..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg deleted file mode 100644 index d0219a75..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-black-panther-wakanda-forever-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg deleted file mode 100644 index f39cf5ce..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg deleted file mode 100644 index 8d50255f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-blame-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg deleted file mode 100644 index f426d0ce..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg deleted file mode 100644 index d3619fbd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg deleted file mode 100644 index e49fe1d5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg deleted file mode 100644 index b54cc5c7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-butterfly-on-a-wheel-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg deleted file mode 100644 index 5700c4e7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg deleted file mode 100644 index 426142ef..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-captain-america-civil-war-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg deleted file mode 100644 index 988e20d5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg deleted file mode 100644 index b58ce600..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-careful-what-you-wish-for-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg deleted file mode 100644 index f785c549..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg deleted file mode 100644 index a6a99fee..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-castle-in-the-sky-1986-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg deleted file mode 100644 index 6b3d02a3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg deleted file mode 100644 index 1def9414..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-chicago-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg deleted file mode 100644 index d41a10ef..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg deleted file mode 100644 index f26414a7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-citizen-kane-1941-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg deleted file mode 100644 index db0a7fcd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg deleted file mode 100644 index c6aecefa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-conclave-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg deleted file mode 100644 index 6c70a452..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg deleted file mode 100644 index 4acfa871..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-crime-of-passion-1956-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg deleted file mode 100644 index bec98992..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg deleted file mode 100644 index 018d4f4b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-deep-water-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg deleted file mode 100644 index e958449b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg deleted file mode 100644 index d93f3124..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-derailed-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg deleted file mode 100644 index 29ecc103..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg deleted file mode 100644 index d3444637..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-house-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg deleted file mode 100644 index 7f5a31ac..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg deleted file mode 100644 index 3b563cf9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-dream-lover-1994-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg deleted file mode 100644 index ee642530..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg deleted file mode 100644 index 4cf7d66b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-drop-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg deleted file mode 100644 index 9096a0fa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg deleted file mode 100644 index 01a26f3b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-eastern-promises-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg deleted file mode 100644 index 8edc02a6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg deleted file mode 100644 index 7a62df45..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg deleted file mode 100644 index b13c33c6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg deleted file mode 100644 index dda9d622..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-fatale-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg deleted file mode 100644 index 3735ee63..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg deleted file mode 100644 index 93ba0d71..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg deleted file mode 100644 index 716b8a87..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg deleted file mode 100644 index 58d221ba..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg deleted file mode 100644 index 4a9931ed..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg deleted file mode 100644 index ae262147..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg deleted file mode 100644 index 42c4cea6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg deleted file mode 100644 index 6a6aa651..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-gosford-park-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg deleted file mode 100644 index 0aeef2ba..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg deleted file mode 100644 index 042a6c80..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-howl-s-moving-castle-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg deleted file mode 100644 index 7cd4ee24..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg deleted file mode 100644 index e05bfdb0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-in-the-heights-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg deleted file mode 100644 index 2a108f0c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg deleted file mode 100644 index 80a59b1e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-kiki-s-delivery-service-1989-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg deleted file mode 100644 index 38203b77..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg deleted file mode 100644 index 4c78fee9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg deleted file mode 100644 index a24e64ab..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg deleted file mode 100644 index 94ebe97d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-locke-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg deleted file mode 100644 index 2fcaeaad..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg deleted file mode 100644 index f3725b42..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-memories-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg deleted file mode 100644 index da38be48..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg deleted file mode 100644 index c6fa6a26..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg deleted file mode 100644 index ec877751..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg deleted file mode 100644 index 0b2a0d30..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-panic-room-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg deleted file mode 100644 index 8aa4ecbd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg deleted file mode 100644 index f548e831..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-ponyo-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg deleted file mode 100644 index 63d8112e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg deleted file mode 100644 index 4a6a2bd1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-porco-rosso-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg deleted file mode 100644 index 9486d2cf..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg deleted file mode 100644 index 21dd9359..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-princess-mononoke-1997-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg deleted file mode 100644 index 724008c5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg deleted file mode 100644 index 411b149b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-prisoners-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg deleted file mode 100644 index 1d332cec..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg deleted file mode 100644 index cc2ef2ce..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-racing-stripes-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg deleted file mode 100644 index 7ea43937..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg deleted file mode 100644 index 33408fa0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-secondhand-lions-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg deleted file mode 100644 index 13f14f93..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg deleted file mode 100644 index 6b2b79ce..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-shaft-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg deleted file mode 100644 index d04685fe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg deleted file mode 100644 index 428bb38b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg deleted file mode 100644 index df5bb046..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg deleted file mode 100644 index a5ffff1f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-sleeping-with-other-people-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg deleted file mode 100644 index c1281a9c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg deleted file mode 100644 index be1873fd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-snowpiercer-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg deleted file mode 100644 index 2fdaadb4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg deleted file mode 100644 index 51fa155f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-brand-new-day-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg deleted file mode 100644 index d09f655a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg deleted file mode 100644 index 28521997..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-far-from-home-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg deleted file mode 100644 index 6088fadf..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg deleted file mode 100644 index 97fc9c05..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-homecoming-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg deleted file mode 100644 index a76afec9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg deleted file mode 100644 index bc92a4cd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spider-man-no-way-home-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg deleted file mode 100644 index af91c123..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg deleted file mode 100644 index 5a79da62..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-spirited-away-2001-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg deleted file mode 100644 index ea619c14..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg deleted file mode 100644 index bb009cbf..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-alto-knights-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg deleted file mode 100644 index 7c5878b7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg deleted file mode 100644 index 4de2711f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg deleted file mode 100644 index 75ae9b64..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg deleted file mode 100644 index c1aec876..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-amazing-spider-man-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg deleted file mode 100644 index 7d67ccb0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg deleted file mode 100644 index 65553d47..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-animatrix-2003-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg deleted file mode 100644 index 83636f25..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg deleted file mode 100644 index be87067c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-avengers-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg deleted file mode 100644 index a19dc77b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg deleted file mode 100644 index 094df64e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-boy-and-the-heron-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg deleted file mode 100644 index 9f979cd8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg deleted file mode 100644 index d1823120..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-breakfast-club-1985-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg deleted file mode 100644 index 2f016498..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg deleted file mode 100644 index a0379f2f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-dry-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg deleted file mode 100644 index 6e920467..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg deleted file mode 100644 index 8fbb8f2a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg deleted file mode 100644 index 94111d9b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-1972-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg deleted file mode 100644 index d2080487..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg deleted file mode 100644 index abbe5e99..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-godfather-part-iii-1990-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg deleted file mode 100644 index 7a4f8a32..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg deleted file mode 100644 index e949fb3e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-great-gatsby-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg deleted file mode 100644 index dd755ee8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg deleted file mode 100644 index 83464d4c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg deleted file mode 100644 index 862c064a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg deleted file mode 100644 index 15f26533..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-lighthouse-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg deleted file mode 100644 index 698f4ad2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg deleted file mode 100644 index fe50c2f8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg deleted file mode 100644 index b9157b91..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg deleted file mode 100644 index 882621cb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg deleted file mode 100644 index bb48460b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg deleted file mode 100644 index 0894754d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-wild-robot-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg deleted file mode 100644 index cb5bfb28..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg deleted file mode 100644 index 6db33db1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg deleted file mode 100644 index aad8ea2c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg deleted file mode 100644 index 8a54bc7f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg deleted file mode 100644 index a35327a9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg deleted file mode 100644 index 5a4ff0ab..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-thelma-louise-1991-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg deleted file mode 100644 index 7c9b445d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg deleted file mode 100644 index aee092c6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-triangle-of-sadness-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg deleted file mode 100644 index 2918db3a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg deleted file mode 100644 index 76855126..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/movie-vice-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg deleted file mode 100644 index 3d2825f5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg deleted file mode 100644 index 96e3596e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-12-monkeys-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg deleted file mode 100644 index dcda667a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg deleted file mode 100644 index 64ca587b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-56-days-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg deleted file mode 100644 index ce12fc71..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg deleted file mode 100644 index 79f1351c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg deleted file mode 100644 index 2df9722d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg deleted file mode 100644 index afd371a9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-absolutely-fabulous-1992-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg deleted file mode 100644 index 1f5f6bdc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg deleted file mode 100644 index 207b1dd1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-accel-world-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg deleted file mode 100644 index d41c6397..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg deleted file mode 100644 index 74af010a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-accused-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg deleted file mode 100644 index eaf9a4fa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg deleted file mode 100644 index 4fae4e33..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-after-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg deleted file mode 100644 index 0c52f2e6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg deleted file mode 100644 index ea7dda99..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-agatha-christie-s-marple-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg deleted file mode 100644 index 7343fb32..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg deleted file mode 100644 index a29d6cb4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-ahsoka-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg deleted file mode 100644 index 909ae1ba..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg deleted file mode 100644 index 5b1666e6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-and-small-1978-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg deleted file mode 100644 index d2bcc88d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg deleted file mode 100644 index b506f990..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-all-creatures-great-small-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg deleted file mode 100644 index 75a1948b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg deleted file mode 100644 index 1f9e239a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-dad-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg deleted file mode 100644 index 4817c690..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg deleted file mode 100644 index c02c46da..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-gods-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg deleted file mode 100644 index bf758ae0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg deleted file mode 100644 index 5f92bf05..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-housewife-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg deleted file mode 100644 index c3bfe5e1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg deleted file mode 100644 index 7f10e6aa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-american-primeval-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg deleted file mode 100644 index 59c981e5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg deleted file mode 100644 index 9904c74b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-and-just-like-that-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-andor-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg deleted file mode 100644 index 199249d7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg deleted file mode 100644 index 67b766b0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-andromeda-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg deleted file mode 100644 index 15e325c9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg deleted file mode 100644 index d0e37959..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg deleted file mode 100644 index e0c97321..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-aqua-teen-hunger-force-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg deleted file mode 100644 index 9300e20f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg deleted file mode 100644 index 6a8f9a24..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg deleted file mode 100644 index b32f5aca..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg deleted file mode 100644 index adee3bb7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-arrow-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg deleted file mode 100644 index 39ecc0d6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg deleted file mode 100644 index 5cd5f098..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-atlantis-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg deleted file mode 100644 index dacfd59e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg deleted file mode 100644 index 61f459c6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-seven-havens-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg deleted file mode 100644 index f60120da..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg deleted file mode 100644 index 6f99263a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2005-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg deleted file mode 100644 index ad740218..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg deleted file mode 100644 index 9d46c2ff..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-avatar-the-last-airbender-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg deleted file mode 100644 index 1ca5e56c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg deleted file mode 100644 index 8da6e8e2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-awkward-2011-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg deleted file mode 100644 index 7ffef681..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg deleted file mode 100644 index b00730a8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-back-to-life-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg deleted file mode 100644 index c4685e84..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg deleted file mode 100644 index f1c18f69..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-ballers-2015-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg deleted file mode 100644 index 4d61dfbe..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg deleted file mode 100644 index ceef0c9d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-baretta-1975-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg deleted file mode 100644 index b59c0e58..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg deleted file mode 100644 index 386d3d90..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-based-on-a-true-story-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg deleted file mode 100644 index 69e567cb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg deleted file mode 100644 index a864f50c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg deleted file mode 100644 index d04a1a20..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg deleted file mode 100644 index 7ca86592..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-batwoman-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg deleted file mode 100644 index c7c91e2d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg deleted file mode 100644 index 93e1e86a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-boys-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg deleted file mode 100644 index 381774e7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg deleted file mode 100644 index ef024946..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-city-greens-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg deleted file mode 100644 index 760e0262..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg deleted file mode 100644 index 4f535fec..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-big-mouth-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg deleted file mode 100644 index 3a0854c9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg deleted file mode 100644 index 733f5cbc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-black-lightning-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg deleted file mode 100644 index 48df9d46..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg deleted file mode 100644 index 3ff9aeed..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-black-rabbit-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg deleted file mode 100644 index ef8eaaa3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg deleted file mode 100644 index b233e033..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-bleach-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg deleted file mode 100644 index 86d3d806..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg deleted file mode 100644 index 73b77df2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-brassic-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg deleted file mode 100644 index e4ac4ae3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg deleted file mode 100644 index c4f7217c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-breaking-bad-2008-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-californication-2007-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg deleted file mode 100644 index f1fbee3e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg deleted file mode 100644 index a3779772..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-chainsaw-man-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg deleted file mode 100644 index 2920cf5a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg deleted file mode 100644 index 8d338532..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-cyberpunk-edgerunners-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg deleted file mode 100644 index 76fae98c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg deleted file mode 100644 index 56a8cd79..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-derry-girls-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg deleted file mode 100644 index 0c4c38bc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-desperate-housewives-2004-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg deleted file mode 100644 index 5b940b9f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg deleted file mode 100644 index eef2a4f3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-dune-prophecy-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg deleted file mode 100644 index a8e9576c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg deleted file mode 100644 index f078bc89..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-etoile-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg deleted file mode 100644 index c9d6a190..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg deleted file mode 100644 index 5e87a07d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-fallout-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg deleted file mode 100644 index fa3b7604..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg deleted file mode 100644 index 9ad599b3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-firefly-2002-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg deleted file mode 100644 index e5a83462..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg deleted file mode 100644 index bd3ccea5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-fleabag-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-gilmore-girls-2000-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-high-potential-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg deleted file mode 100644 index e8120387..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg deleted file mode 100644 index c278e37e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-invincible-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg deleted file mode 100644 index c85a210b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg deleted file mode 100644 index a9789909..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-last-samurai-standing-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg deleted file mode 100644 index 1249a8f3..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg deleted file mode 100644 index b1229ed8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-lord-of-the-flies-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg deleted file mode 100644 index 3694bfc4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg deleted file mode 100644 index 08449aa9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-love-death-robots-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg deleted file mode 100644 index 9d46de1d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg deleted file mode 100644 index a1985217..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-maniac-2018-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg deleted file mode 100644 index d80e17c1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg deleted file mode 100644 index f86cd698..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-neon-genesis-evangelion-1995-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg deleted file mode 100644 index 58805e67..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg deleted file mode 100644 index e7206b88..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-norsemen-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg deleted file mode 100644 index 3fa4a282..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg deleted file mode 100644 index 286e6260..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-one-piece-2023-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg deleted file mode 100644 index 0b768d54..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg deleted file mode 100644 index a6b147a4..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-only-murders-in-the-building-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg deleted file mode 100644 index 43ac4428..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg deleted file mode 100644 index 34b1ed84..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-over-the-garden-wall-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg deleted file mode 100644 index e64c0268..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg deleted file mode 100644 index 1ea4711b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-pluribus-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-rick-and-morty-2013-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg deleted file mode 100644 index 54d2a13e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg deleted file mode 100644 index 1b6e67f6..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-secret-level-2024-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg deleted file mode 100644 index a24c03bd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg deleted file mode 100644 index 5a29d6f9..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-severance-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-sex-and-the-city-1998-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg deleted file mode 100644 index 8d41cb22..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg deleted file mode 100644 index 4d254cb0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-100-2014-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg deleted file mode 100644 index 53e1d593..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg deleted file mode 100644 index 72ca19db..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg deleted file mode 100644 index 9beacde2..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-afterparty-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bear-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg deleted file mode 100644 index e7727a79..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg deleted file mode 100644 index c78c5cf0..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-beast-in-me-2025-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg deleted file mode 100644 index dd84deab..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg deleted file mode 100644 index f663eb8b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-bold-type-2017-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg deleted file mode 100644 index a834d9fb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg deleted file mode 100644 index f67ff16f..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-boys-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg deleted file mode 100644 index 42256fc1..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg deleted file mode 100644 index a4039011..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-capture-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-durrells-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg deleted file mode 100644 index 61a310d8..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg deleted file mode 100644 index f0332826..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-final-problem-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg deleted file mode 100644 index ab6086bd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg deleted file mode 100644 index a7086d5e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-legend-of-korra-2012-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg deleted file mode 100644 index 0b88fd4b..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg deleted file mode 100644 index dc387fcd..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg deleted file mode 100644 index 327aa449..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg deleted file mode 100644 index 7ab2dbf5..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-night-manager-2016-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg deleted file mode 100644 index a267b4cb..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg deleted file mode 100644 index 5324dc75..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-tatami-galaxy-2010-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-white-lotus-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg deleted file mode 100644 index acca834e..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg deleted file mode 100644 index f5eb8f59..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-the-witcher-2019-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg deleted file mode 100644 index f350a49c..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg deleted file mode 100644 index 547d3b7a..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-upload-2020-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg deleted file mode 100644 index 03b1c335..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg deleted file mode 100644 index 8c02a0d7..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-widow-s-bay-2026-poster.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg deleted file mode 100644 index 960c2d23..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-backdrop.jpg and /dev/null differ diff --git a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg b/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg deleted file mode 100644 index c7e16a57..00000000 Binary files a/app/src/debug/assets/wolfskin-titles/assets/tv-yellowjackets-2021-poster.jpg and /dev/null differ diff --git a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt deleted file mode 100644 index 8eb4a397..00000000 --- a/app/src/debug/java/org/justinvisser/wolfskin/tv/DebugHome.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import org.justinvisser.wolfskin.design.HomeFixture -import org.justinvisser.wolfskin.design.HomeSnapshot - -/** Only debug builds may read the locked prototype household projection. */ -fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = - if (ConductarrPreference.fixtures) HomeFixture(context).snapshot(person) else null diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml deleted file mode 100644 index cb6c15c6..00000000 --- a/app/src/debug/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="app_name" translatable="false">Wolfskin (Debug)</string> -</resources> diff --git a/app/src/default/AndroidManifest.xml b/app/src/default/AndroidManifest.xml deleted file mode 100644 index c59612c7..00000000 --- a/app/src/default/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android"> - <queries> - <intent> - <action android:name="android.intent.action.VIEW" /> - <data android:scheme="vnd.youtube" /> - </intent> - </queries> -</manifest> diff --git a/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml deleted file mode 100644 index ec3f55e3..00000000 --- a/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<bitmap xmlns:android="http://schemas.android.com/apk/res/android" - android:src="@mipmap/ic_banner_foreground" - android:gravity="fill" /> diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher.webp b/app/src/default/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index 4ac23262..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp deleted file mode 100644 index de1628cc..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index df5faba9..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher.webp b/app/src/default/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 415cf194..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp deleted file mode 100644 index d9df942a..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index dea8ff64..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner.png b/app/src/default/res/mipmap-xhdpi/ic_banner.png deleted file mode 100644 index ee42b91e..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_banner.png and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png deleted file mode 100644 index ee42b91e..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 924b40eb..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp deleted file mode 100644 index e218f8bd..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 40674eff..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 6f5be441..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp deleted file mode 100644 index 8c1935c7..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 48a4e286..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index 1cf49fb6..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp deleted file mode 100644 index afc41a8b..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 122a33e0..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/values-hu/strings.xml b/app/src/default/res/values-hu/strings.xml deleted file mode 100644 index fc653df6..00000000 --- a/app/src/default/res/values-hu/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="display_preset_default">Wolfskin alapértelmezett beállítása</string> - <string name="display_preset_compact">Wolfskin kompakt elrendezés</string> - <string name="updated_toast">A Wolfskin frissült a %s verzióra</string> -</resources> diff --git a/app/src/default/res/values-pl/strings.xml b/app/src/default/res/values-pl/strings.xml deleted file mode 100644 index e334b3ba..00000000 --- a/app/src/default/res/values-pl/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">O Wolfskin</string> -</resources> diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml deleted file mode 100644 index c76d6792..00000000 --- a/app/src/default/res/values/strings.xml +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="app_name" translatable="false">Wolfskin</string> - <string name="app_name_long" translatable="false">Wolfskin</string> - <string name="display_preset_default">Wolfskin Default</string> - <string name="display_preset_compact">Wolfskin Compact</string> - <string name="updated_toast">Wolfskin updated to %s</string> - <string name="wolfskin_conductarr_url">Conductarr server URL</string> - <string name="wolfskin_conductarr_url_summary">Advanced override. HTTPS required; blank restores the build default.</string> - <string name="wolfskin_fixtures">Wolfskin preview fixtures (debug only)</string> -</resources> diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml deleted file mode 100644 index 8a762a28..00000000 --- a/app/src/firetv/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools"> - - <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:node="remove" /> - -</manifest> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aad5c5b5..6d84299e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:installLocation="auto"> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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" /> @@ -14,30 +11,17 @@ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> - <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> - <uses-permission android:name="com.amazon.permission.media.session.voicecommandcontrol" /> <uses-feature android:name="android.hardware.touchscreen" android:required="false" /> <uses-feature android:name="android.software.leanback" - android:required="${leanback}" /> + android:required="false" /> <uses-feature android:name="android.hardware.microphone" android:required="false" /> - <!-- Required for Android 11+ to query voice recognition availability --> - <queries> - <intent> - <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> - </intent> - <intent> - <action android:name="android.intent.action.VIEW" /> - <data android:mimeType="video/*" /> - </intent> - </queries> - <application android:allowBackup="true" android:banner="@mipmap/ic_banner" @@ -46,28 +30,18 @@ android:supportsRtl="true" android:theme="@style/Theme.Wholphin" android:name=".WholphinApplication" - android:usesCleartextTraffic="true" - android:networkSecurityConfig="@xml/network_security_config" - android:largeHeap="true"> + android:usesCleartextTraffic="true"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|density"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"> <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 @@ -79,35 +53,6 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> </provider> - <provider - android:name="androidx.startup.InitializationProvider" - android:authorities="${applicationId}.androidx-startup" - tools:node="merge"> - <meta-data - android:name="androidx.work.WorkManagerInitializer" - android:value="androidx.startup" - tools:node="remove" /> - </provider> - - <service - android:name=".WholphinDreamService" - android:exported="true" - android:label="@string/app_name" - android:permission="android.permission.BIND_DREAM_SERVICE"> - <intent-filter> - <action android:name="android.service.dreams.DreamService" /> - <category android:name="android.intent.category.DEFAULT" /> - </intent-filter> - </service> - - <service - android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" - android:enabled="false" - android:exported="false"> - <meta-data - android:name="autoStoreLocales" - android:value="false" /> - </service> </application> </manifest> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index dfc380e1..87623e5e 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ 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 ec61583d..175db929 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,105 +1,61 @@ package com.github.damontecres.wholphin -import android.content.Context -import android.content.Intent -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.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.livedata.observeAsState 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.graphics.RectangleShape +import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore -import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope -import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.rememberNavBackStack 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 +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler -import com.github.damontecres.wholphin.services.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 -import com.github.damontecres.wholphin.services.RefreshRateService -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.ServerEventListener -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.collectLatestIn -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService -import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel -import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock -import com.github.damontecres.wholphin.util.DebugLogTree -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.github.damontecres.wholphin.util.requestSerializersModule +import com.github.damontecres.wholphin.util.profile.createDeviceProfile import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -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.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class MainActivity : AppCompatActivity() { - private val viewModel: MainActivityViewModel by viewModels() - private val playExternalViewModel: PlayExternalViewModel by viewModels() + @Inject + lateinit var serverRepository: ServerRepository @Inject lateinit var userPreferencesDataStore: DataStore<AppPreferences> - @Inject - lateinit var userPreferencesService: UserPreferencesService - @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @@ -108,183 +64,107 @@ class MainActivity : AppCompatActivity() { lateinit var navigationManager: NavigationManager @Inject - lateinit var setupNavigationManager: SetupNavigationManager + lateinit var updateChecker: UpdateChecker @Inject - lateinit var updateChecker: UpdateChecker + lateinit var appUpgradeHandler: AppUpgradeHandler + + @Inject + lateinit var serverEventListener: ServerEventListener @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver - @Inject - lateinit var imageUrlService: ImageUrlService - - @Inject - lateinit var seerrRatingsService: SeerrRatingsService - - @Inject - lateinit var refreshRateService: RefreshRateService - - @Inject - lateinit var userSwitchListener: UserSwitchListener - - @Inject - lateinit var tvProviderSchedulerService: TvProviderSchedulerService - - @Inject - lateinit var suggestionsSchedulerService: SuggestionsSchedulerService - - @Inject - lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService - - @Inject - lateinit var backdropService: BackdropService - - // Note: unused but injected to ensure it is created - @Inject - lateinit var serverEventListener: ServerEventListener - - // Note: unused but injected to ensure it is created - @Inject - lateinit var datePlayedInvalidationService: DatePlayedInvalidationService - - @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) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - instance = this - Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") + Timber.i("MainActivity.onCreate") lifecycle.addObserver(playbackLifecycleObserver) - - val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) - 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 = restoredBackStack - if (!playExternalViewModel.launched.value) { - val lastDest = backStack.lastOrNull() - if (lastDest.isPlayback) { - Timber.v("Restoring back stack without playback") - backStack = backStack.toMutableList().apply { removeAt(lastIndex) } - } - } - navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) - } else { - navigationManager.backStack = NavBackStack(Destination.Home()) + if (savedInstanceState == null) { + appUpgradeHandler.copySubfont(false) } - - 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(WholphinDispatchers.Main) { - if (keepScreenOn) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - }.catch { ex -> - Timber.e(ex, "Error with keepScreenOn") - }.launchIn(lifecycleScope) - - userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs -> - signInAuto = prefs.signInAutomatically - playerBackend = prefs.playbackPreferences.playerBackend - } - - viewModel.appStart(intent) setContent { - MaterialTheme { - 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) } + CoilConfig(okHttpClient, false) + val appPreferences by userPreferencesDataStore.data.collectAsState(null) + appPreferences?.let { appPreferences -> + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, + ) { + Surface( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { + var isRestoringSession by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - delay(500.milliseconds) - Timber.i("Showing loading page") - showLoading = true - } - if (showLoading) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black), - ) { - LoadingPage() + try { + serverRepository.restoreSession( + appPreferences.currentServerId?.toUUIDOrNull(), + appPreferences.currentUserId?.toUUIDOrNull(), + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception restoring session") } + isRestoringSession = false } - } else { - userPreferences?.let { userPreferences -> - val appPreferences = userPreferences.appPreferences - CoilConfig( - prefs = appPreferences, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, + val server by serverRepository.currentServer.observeAsState() + val user by serverRepository.currentUser.observeAsState() + val userDto by serverRepository.currentUserDto.observeAsState() + + val preferences = + UserPreferences( + appPreferences, + userDto?.configuration ?: DefaultUserConfiguration, ) - LaunchedEffect(appPreferences.debugLogging) { - DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging - } - CompositionLocalProvider( - LocalImageUrlService provides imageUrlService, - LocalSeerrRatingsService provides seerrRatingsService, + + if (isRestoringSession) { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, ) { - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - ProvideLocalClock { - MainContent( - backStack = setupNavigationManager.backStack, - navigationManager = navigationManager, - userPreferences = userPreferences, - backdropService = backdropService, - screensaverService = screensaverService, - modifier = Modifier.fillMaxSize(), - ) + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } else { + key(server, user) { + val initialDestination = + if (server != null && user != null) { + Destination.Home() + } else { + Destination.ServerList + } + val backStack = rememberNavBackStack(initialDestination) + navigationManager.backStack = backStack + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + LaunchedEffect(Unit) { + try { + updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) + } catch (ex: Exception) { + Timber.w(ex, "Failed to check for update") + } } } + val deviceProfile = + remember(server, appPreferences) { + createDeviceProfile( + this@MainActivity, + preferences, + server?.serverVersion, + ) + } + ApplicationContent( + user = user, + server = server, + navigationManager = navigationManager, + preferences = preferences, + deviceProfile = deviceProfile, + modifier = Modifier.fillMaxSize(), + ) } } } @@ -293,301 +173,10 @@ class MainActivity : AppCompatActivity() { } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (screensaverService.state.value.run { show || showDim }) { - screensaverService.stop(false) - screensaverService.pulse() - return true - } else { - screensaverService.pulse() - return super.dispatchKeyEvent(event) - } - } - override fun onResume() { super.onResume() - Timber.d("onResume") - viewModel.appResume() - lifecycleScope.launchDefault { - screensaverService.pulse() + lifecycleScope.launchIO { + appUpgradeHandler.run() } } - - override fun onRestart() { - super.onRestart() - Timber.d("onRestart") - viewModel.appStart(null) - if (!playExternalViewModel.launched.value) { - // If restarting during playback that is not external, go back a page - val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest.isPlayback) { - Timber.v("onRestart: go back from playback") - navigationManager.goBack() - } - } - } - - override fun onStop() { - super.onStop() - Timber.d("onStop") - screensaverService.stop(true) - tvProviderSchedulerService.launchOneTimeRefresh() - } - - override fun onPause() { - super.onPause() - Timber.d("onPause") - } - - override fun onStart() { - super.onStart() - Timber.d("onStart") - - lifecycleScope.launchDefault { - val appPreferences = userPreferencesDataStore.data.first() - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - Timber.d("onSaveInstanceState") - 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) - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - Timber.d("onRestoreInstanceState") - } - - override fun onDestroy() { - super.onDestroy() - Timber.d("onDestroy") - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - Timber.d("onConfigurationChanged: newConfig=%s", newConfig) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - Timber.v("onNewIntent") - setIntent(intent) - viewModel.appStart(intent) - } - - fun changeDisplayMode(modeId: Int) { - 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 { - private const val KEY_BACK_STACK = "backStack" - private const val KEY_EXTERNAL_PLAYER = "extPlayer" - - lateinit var instance: MainActivity - private set - } } - -@HiltViewModel -class MainActivityViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val preferences: DataStore<AppPreferences>, - val serverRepository: ServerRepository, - 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() { - private val mutex = Mutex() - - fun appStart(intent: Intent?) { - viewModelScope.launchDefault { - mutex.withLock { - try { - handleAppUpgrade() - } catch (ex: Exception) { - Timber.e(ex, "Error during app upgrade!") - showToast( - context, - "Error during app upgrade: ${ex.localizedMessage}", - ) - } - - try { - val result = handleIntent(intent) - if (result) { - Timber.i("appStart was handled by intent") - return@withLock - } - } catch (ex: Exception) { - Timber.e(ex, "Error parsing intent") - } - - try { - handleAppStart() - } catch (ex: Exception) { - Timber.e(ex, "Error during appStart") - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } - } - viewModelScope.launchDefault { - // Create the mediaCodecCapabilitiesTest if needed - 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") - } - } - } - } - - /** - * Handle an [Intent], navigating if needed - * - * @return true if the intent was handled, false if normal app start should proceed - */ - @VisibleForTesting - internal suspend fun handleIntent(intent: Intent?): Boolean { - 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 true - } - - 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 true - } - - IntentResult.NoOp, - null, - -> { - // Not handled - return false - } - } - } - - private suspend fun handleAppUpgrade() { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() - } - appUpgradeHandler.copySubfont(false) - } - - /** - * Handle starting the app including auto sign in and navigating to the right starting page - */ - @VisibleForTesting - internal suspend fun handleAppStart() { - val signInAutomatically = preferences.data.first().signInAutomatically - val currentUser = serverRepository.current.value - if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { - // Hot reload - Timber.v("Hot reload restoring state") - setupNavigationManager.navigateTo( - SetupDestination.AppContent(currentUser), - ) - } else { - val restoredSession = - if (signInAutomatically) { - Timber.v("Sign in automatically") - serverRepository.restoreLastSession() - } else { - serverRepository.getMostRecentServer() - } - Timber.d("Restore last session result=%s", restoredSession) - setupNavigationManager.navigateTo(restoredSession.destination) - } - } - } - -private val Destination?.isPlayback: Boolean - get() = - this is Destination.Playback || - this is Destination.PlaybackList || - this is Destination.Slideshow diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt deleted file mode 100644 index fcaa3165..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.github.damontecres.wholphin - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -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.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 -import androidx.lifecycle.compose.LifecycleEventEffect -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.ui.components.AppScreensaver -import org.justinvisser.wolfskin.tv.WolfskinApp -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent -import com.github.damontecres.wholphin.ui.util.InterfaceCustomization -import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization - -@Composable -fun MainContent( - backStack: MutableList<SetupDestination>, - navigationManager: NavigationManager, - userPreferences: UserPreferences, - backdropService: BackdropService, - screensaverService: ScreensaverService, - modifier: Modifier = Modifier, -) { - val preferences by rememberUpdatedState(userPreferences) - Surface( - modifier = - modifier - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val interfaceCustomization = - remember(userPreferences.appPreferences) { InterfaceCustomization(userPreferences.appPreferences) } - CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) { - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - server = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - LaunchedEffect(Unit) { - backdropService.clearBackdrop() - } - val current = key.current - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!userPreferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - WolfskinApp( - user = current.user, - server = current.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } - }, - ) - val screenSaverState by screensaverService.state.collectAsState() - if (screenSaverState.enabled || screenSaverState.enabledTemp) { - AnimatedVisibility( - visible = screenSaverState.show, - enter = ScreensaverService.enterAnimation, - exit = ScreensaverService.exitAnimation, - modifier = 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 6ec9d4b4..7418dbc5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -2,28 +2,20 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build -import android.os.StrictMode import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi -import androidx.hilt.work.HiltWorkerFactory -import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import org.acra.ACRA 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 @OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp -class WholphinApplication : - Application(), - Configuration.Provider { +class WholphinApplication : Application() { init { instance = this @@ -54,23 +46,6 @@ class WholphinApplication : override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .penaltyDeathOnNetwork() - .build(), - ) -// StrictMode.setVmPolicy( -// StrictMode.VmPolicy -// .Builder() -// .detectAll() -// .penaltyLog() -// .build(), -// ) - } initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON @@ -107,22 +82,8 @@ class WholphinApplication : ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } - @Inject - lateinit var workerFactory: HiltWorkerFactory - - override val workManagerConfiguration: Configuration - get() = - Configuration - .Builder() - .setWorkerFactory(workerFactory) - .build() - companion object { lateinit var instance: WholphinApplication private set - - val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion - - val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt deleted file mode 100644 index 3bc17f3c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ /dev/null @@ -1,148 +0,0 @@ -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 -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView -import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.SavedStateRegistry -import androidx.savedstate.SavedStateRegistryController -import androidx.savedstate.SavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.ui.CoilConfig -import com.github.damontecres.wholphin.ui.components.AppScreensaverContent -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import okhttp3.OkHttpClient -import timber.log.Timber -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -@AndroidEntryPoint -class WholphinDreamService : - DreamService(), - SavedStateRegistryOwner { - @Inject - lateinit var serverRepository: ServerRepository - - @Inject - lateinit var screensaverService: ScreensaverService - - @Inject - lateinit var preferencesDataStore: DataStore<AppPreferences> - - @AuthOkHttpClient - @Inject - lateinit var okHttpClient: OkHttpClient - - private val lifecycleRegistry = LifecycleRegistry(this) - - private val savedStateRegistryController = - SavedStateRegistryController.create(this).apply { - performAttach() - } - - override val lifecycle: Lifecycle get() = lifecycleRegistry - override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry - - override fun onCreate() { - super.onCreate() - Timber.d("onCreate") - - savedStateRegistryController.performRestore(null) - lifecycleRegistry.currentState = Lifecycle.State.CREATED - lifecycleScope.launchDefault { - if (serverRepository.current.value == null) { - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { - Timber.w("Could not restore user session") - } - } - } - } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - Timber.d("onAttachedToWindow") - val itemFlow = screensaverService.createItemFlow(lifecycleScope) - setContentView( - ComposeView(this).apply { - setViewTreeLifecycleOwner(this@WholphinDreamService) - setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) - setContent { - val user by serverRepository.currentUserFlow.collectAsState(null) - if (user != null) { - var prefs by remember { mutableStateOf<AppPreferences?>(null) } - LaunchedEffect(Unit) { - preferencesDataStore.data.collectLatest { prefs = it } - } - prefs?.let { prefs -> - CoilConfig( - prefs = prefs, - okHttpClient = okHttpClient, - debugLogging = false, - enableCache = true, - ) - WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) { - ProvideLocalClock { - val screensaverPrefs = prefs.interfacePreferences.screensaverPreference - val currentItem by itemFlow.collectAsState(null) - 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)), - ) - } - } - } - } - } - } - } - }, - ) - } - - override fun onDreamingStarted() { - super.onDreamingStarted() - Timber.d("onDreamingStarted") - lifecycleRegistry.currentState = Lifecycle.State.STARTED - } - - override fun onDreamingStopped() { - super.onDreamingStopped() - Timber.d("onDreamingStopped") - lifecycleRegistry.currentState = Lifecycle.State.DESTROYED - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt b/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt deleted file mode 100644 index aa023e97..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.damontecres.wholphin - -/** Wolfskin-specific defaults kept in one place to keep upstream diffs small. */ -object WolfskinDefaults { - const val SEERR_URL = "https://seerr.justinvisser.org" -} - -/** Returns [url] when it has content, otherwise the Wolfskin Seerr URL so the user can still edit it. */ -fun seerrUrlPrefill(url: String = ""): String = if (url.isBlank()) WolfskinDefaults.SEERR_URL else url diff --git a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt deleted file mode 100644 index 4a76a00c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.damontecres.wholphin.api.seerr - -import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import okhttp3.Call -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import timber.log.Timber - -class SeerrApiClient( - val baseUrl: String, - private val apiKey: String?, - okHttpClient: OkHttpClient, -) { - private val cookieJar = SeerrCookieJar() - - private val client = - okHttpClient - .newBuilder() - .cookieJar(cookieJar) - .addInterceptor { - Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}") - it.proceed( - it - .request() - .newBuilder() - .apply { - if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey) - }.build(), - ) - }.build() - - val hasValidCredentials: Boolean - get() = - apiKey.isNotNullOrBlank() || - cookieJar.hasValidCredentials(baseUrl) - - private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> = - lazy { - initializer.invoke(baseUrl, client) - } - - val authApi by create(::AuthApi) - val blacklistApi by create(::BlacklistApi) - val collectionApi by create(::CollectionApi) - val issueApi by create(::IssueApi) - val mediaApi by create(::MediaApi) - val moviesApi by create(::MoviesApi) - val otherApi by create(::OtherApi) - val overrideruleApi by create(::OverrideruleApi) - val personApi by create(::PersonApi) - val publicApi by create(::PublicApi) - val requestApi by create(::RequestApi) - val searchApi by create(::SearchApi) - val serviceApi by create(::ServiceApi) - val settingsApi by create(::SettingsApi) - val tmdbApi by create(::TmdbApi) - val tvApi by create(::TvApi) - val usersApi by create(::UsersApi) - val watchlistApi by create(::WatchlistApi) -} - -private class SeerrCookieJar : CookieJar { - private val cookies = mutableMapOf<String, List<Cookie>>() - - override fun saveFromResponse( - url: HttpUrl, - cookies: List<Cookie>, - ) { - cookies - .filter { it.name == "connect.sid" } - .groupBy { it.domain } - .forEach { (domain, cookies) -> - this.cookies[domain] = cookies - } - } - - override fun loadForRequest(url: HttpUrl): List<Cookie> = this.cookies[url.host].orEmpty() - - fun hasValidCredentials(baseUrl: String): Boolean = - baseUrl.toHttpUrlOrNull()?.host?.let { domain -> - cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() } - } == true -} 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 45af2d65..272ff86d 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 @@ -9,49 +9,20 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.ItemTrackModification import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.PlaybackEffect -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice -import com.github.damontecres.wholphin.data.model.RememberedTab -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.ui.components.ViewOptions -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.toUUID import timber.log.Timber -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import java.util.UUID @Database( - entities = [ - JellyfinServer::class, - JellyfinUser::class, - ItemPlayback::class, - NavDrawerPinnedItem::class, - LibraryDisplayInfo::class, - PlaybackEffect::class, - PlaybackLanguageChoice::class, - ItemTrackModification::class, - SeerrServer::class, - SeerrUser::class, - RememberedTab::class, - SeriesTrackChoice::class, - ], - version = 41, + entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], + version = 8, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -59,19 +30,6 @@ import java.util.UUID AutoMigration(5, 6), AutoMigration(6, 7), AutoMigration(7, 8), - AutoMigration(8, 9), - AutoMigration(9, 10), - AutoMigration(10, 11), - AutoMigration(11, 12), - AutoMigration(12, 20), - AutoMigration(20, 30), - AutoMigration(30, 31), - AutoMigration(31, 32), - AutoMigration(32, 33), - AutoMigration(33, 34), - AutoMigration(34, 35), - AutoMigration(35, 40), - AutoMigration(40, 41), ], ) @TypeConverters(Converters::class) @@ -83,24 +41,9 @@ abstract class AppDatabase : RoomDatabase() { abstract fun serverPreferencesDao(): ServerPreferencesDao abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao - - abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao - - abstract fun seerrServerDao(): SeerrServerDao - - abstract fun playbackEffectDao(): PlaybackEffectDao - - abstract fun rememberedTabDao(): RememberedTabDao - - abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao } class Converters { - private val json = - Json { - ignoreUnknownKeys = true - } - @TypeConverter fun convertToString(id: UUID): String = id.toString().replace("-", "") @@ -120,49 +63,16 @@ class Converters { fun convertSortOrder(sort: String): SortOrder? = SortOrder.fromNameOrNull(sort) @TypeConverter - fun convertGetItemsFilter(filter: GetItemsFilter): String = json.encodeToString(filter) + fun convertGetItemsFilter(filter: GetItemsFilter): String = Json.encodeToString(filter) @TypeConverter fun convertGetItemsFilter(filter: String): GetItemsFilter = try { - json.decodeFromString(filter) + Json.decodeFromString(filter) } catch (ex: Exception) { Timber.e(ex, "Error parsing filter") GetItemsFilter() } - - @TypeConverter - fun convertViewOptions(viewOptions: ViewOptions?): String? = viewOptions?.let { json.encodeToString(viewOptions) } - - @TypeConverter - fun convertViewOptions(viewOptions: String?): ViewOptions? = - try { - viewOptions?.let { json.decodeFromString(viewOptions) } - } catch (ex: Exception) { - Timber.e(ex, "Error parsing view options") - null - } - - @TypeConverter - fun convertToString(dateTime: ZonedDateTime): String = DateTimeFormatter.ISO_ZONED_DATE_TIME.format(dateTime) - - @TypeConverter - fun convertToLocalDateTime(dateTime: String): ZonedDateTime = ZonedDateTime.parse(dateTime, DateTimeFormatter.ISO_ZONED_DATE_TIME) -} - -class ZonedDateTimeSerializer : KSerializer<ZonedDateTime> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor(ZonedDateTime::class.qualifiedName!!, PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): ZonedDateTime = - ZonedDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_ZONED_DATE_TIME) - - override fun serialize( - encoder: Encoder, - value: ZonedDateTime, - ) { - encoder.encodeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value)) - } } object Migrations { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index 490103b8..669c56fb 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 @@ -4,115 +4,61 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.util.ResStringProvider -import com.github.damontecres.wholphin.util.GetItemsRequestHandler import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.ExtraType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -/** - * Represents "extras" for media such as behind-the-scenes or deleted scenes - */ sealed interface ExtrasItem { val parentId: UUID val type: ExtraType val destination: Destination + val title: String? val imageUrl: String? - val title: String - val subtitle: String? - val isPlayed: Boolean - val playedPercentage: Double - val aspectRatio: Float? - /** - * Represents multiple extras of the same type - */ data class Group( override val parentId: UUID, override val type: ExtraType, val items: List<BaseItem>, - override val imageUrl: String?, - override val title: String, - override val subtitle: String, - override val isPlayed: Boolean, - override val aspectRatio: Float?, ) : ExtrasItem { override val destination: Destination = - Destination.ItemGrid( - title = ResStringProvider(type.stringRes), - request = - GetItemsRequest( - ids = items.map { it.id }, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ), - requestHandler = GetItemsRequestHandler, - initialPosition = 0, - viewOptions = - ViewOptions( - columns = 3, - spacing = 24, - aspectRatio = AspectRatio.WIDE, - ), - ) + Destination.ItemGrid(null, type.stringRes, items.map { it.id }) - override val playedPercentage - get() = -1.0 + override val title: String? = null + override val imageUrl: String? = items.random().imageUrl } - /** - * Represents a single extra - */ data class Single( override val parentId: UUID, override val type: ExtraType, val item: BaseItem, - override val imageUrl: String?, - override val title: String, - override val subtitle: String?, ) : ExtrasItem { override val destination: Destination = Destination.Playback( item = item, ) - override val isPlayed: Boolean - get() = item.played - override val playedPercentage - get() = item.data.userData?.playedPercentage ?: 0.0 - override val aspectRatio: Float? - get() = item.aspectRatio + override val title: String? get() = item.title + override val imageUrl: String? get() = item.imageUrl } } -/** - * Converts [ExtraType] to the string resource ID - */ @get:StringRes val ExtraType.stringRes: Int get() = when (this) { - 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 + 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 } -/** - * Converts [ExtraType] to the plural resource ID - */ @get:PluralsRes val ExtraType.pluralRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt index a3880d30..e0743040 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt @@ -1,50 +1,29 @@ package com.github.damontecres.wholphin.data import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.ItemTrackModification import com.github.damontecres.wholphin.data.model.JellyfinUser import java.util.UUID @Dao interface ItemPlaybackDao { - suspend fun getItem( + fun getItem( user: JellyfinUser, itemId: UUID, ): ItemPlayback? = getItem(user.rowId, itemId) @Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId") - suspend fun getItem( + fun getItem( userId: Int, itemId: UUID, ): ItemPlayback? @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveItem(item: ItemPlayback): Long - - @Delete - suspend fun deleteItem(item: ItemPlayback) + fun saveItem(item: ItemPlayback): Long @Query("SELECT * from ItemPlayback WHERE userId=:userId") - suspend fun getItems(userId: Int): List<ItemPlayback> - - @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId") - suspend fun getTrackModifications( - userId: Int, - itemId: UUID, - ): List<ItemTrackModification> - - @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId AND trackIndex=:trackIndex") - suspend fun getTrackModifications( - userId: Int, - itemId: UUID, - trackIndex: Int, - ): ItemTrackModification? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun saveItem(item: ItemTrackModification): Long + fun getItems(userId: Int): List<ItemPlayback> } 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 702ecfdb..97537146 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 @@ -2,17 +2,10 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.ItemTrackModification -import com.github.damontecres.wholphin.data.model.RelativeMediaStream -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceResult -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.data.model.chooseSource +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -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.serializer.toUUIDOrNull @@ -20,124 +13,66 @@ import timber.log.Timber import java.util.UUID 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 constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val seriesTrackChoiceDao: SeriesTrackChoiceDao, - 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( + fun getSelectedTracks( itemId: UUID, item: BaseItem, - prefs: UserPreferences, ): ChosenStreams? = - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - return getChosenItemFromPlayback(item, itemPlayback, prefs) + if (itemPlayback != null) { + Timber.v("Got itemPlayback for %s", itemId) + getChosenItemFromPlayback(item, itemPlayback) + } else { + null + } } - suspend fun getChosenItemFromPlayback( - item: BaseItem, - itemPlayback: ItemPlayback?, - prefs: UserPreferences, - ): ChosenStreams? { - val audioStc = - streamChoiceService.getSeriesTrackChoices( - item.data, - SeriesTrackChoiceType.AUDIO, - ) - val subtitleStc = - streamChoiceService.getSeriesTrackChoices( - item.data, - SeriesTrackChoiceType.SUBTITLE, - ) - return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs) - } - - /** - * Get the chosen source & stream for the given item - * - * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null - */ fun getChosenItemFromPlayback( item: BaseItem, - itemPlayback: ItemPlayback?, - audioStc: List<SeriesTrackChoice>, - subtitleStc: List<SeriesTrackChoice>, - prefs: UserPreferences, + itemPlayback: ItemPlayback, ): ChosenStreams? { - Timber.v( - "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s", - item.id, - itemPlayback != null, - audioStc.isNotEmpty(), - subtitleStc.isNotEmpty(), - ) val source = - item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } - ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) + item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } if (source != null) { - val audioResult = - streamChoiceService.chooseAudioStream( - candidates = - source.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - .orEmpty(), - itemPlayback = itemPlayback, - stc = audioStc, - prefs = prefs, - ) + val audioStream = + if (itemPlayback.audioIndexEnabled) { + source.mediaStreams?.firstOrNull { it.index == itemPlayback.audioIndex } + } else { + null + } val subtitleStream = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioResult.stream?.language, - candidates = - source.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - .orEmpty(), - itemPlayback = itemPlayback, - stc = subtitleStc, - prefs = prefs, - ) + if (itemPlayback.subtitleIndexEnabled) { + source.mediaStreams?.firstOrNull { it.index == itemPlayback.subtitleIndex } + } else { + null + } return ChosenStreams( - itemPlayback = itemPlayback, - audioStc = audioStc, - subtitleStc = subtitleStc, - itemId = item.id, - source = source, - videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioResult, - subtitleStream = subtitleStream, - subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, + itemPlayback, + item.id, + source.id?.toUUIDOrNull(), + audioStream, + subtitleStream, + itemPlayback.subtitleIndex == TrackIndex.DISABLED, ) } else { return null } } - /** - * Save the choice for a specific media source for an item - */ suspend fun savePlayVersion( itemId: UUID, sourceId: UUID, ): ItemPlayback? = - withContext(WholphinDispatchers.IO) { - serverRepository.currentUser?.let { user -> + withContext(Dispatchers.IO) { + serverRepository.currentUser.value?.let { user -> val itemPlayback = ItemPlayback( userId = user.rowId, @@ -149,78 +84,27 @@ 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?, trackIndex: Int, type: MediaStreamType, - ): ItemPlayback = - serverRepository.current.value!!.let { current -> - val source = - itemPlayback?.sourceId?.let { sourceId -> - item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == sourceId } - } ?: streamChoiceService.chooseSource(item.data, null) - if (source == null) { - Timber.w("Could not find media source for ${item.id}") - throw IllegalArgumentException("Could not find media source for ${item.id}") + ) = serverRepository.currentUser.value?.let { user -> + var toSave = + itemPlayback ?: ItemPlayback( + userId = user.rowId, + itemId = item.id, + sourceId = chooseSource(item.data, null)?.id?.toUUIDOrNull(), + ) + toSave = + when (type) { + MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex) + MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex) + else -> toSave } - var toSave = - itemPlayback ?: ItemPlayback( - userId = current.user.rowId, - itemId = item.id, - sourceId = source.id?.toUUIDOrNull(), - ) - toSave = - when (type) { - MediaStreamType.AUDIO -> toSave.copy(audioIndex = trackIndex) - MediaStreamType.SUBTITLE -> toSave.copy(subtitleIndex = trackIndex) - else -> toSave - } - Timber.v("Saving track selection %s", toSave) - toSave = saveItemPlayback(toSave) - val seriesId = item.data.seriesId - if (seriesId != null || item.data.parentId != null) { - if (type == MediaStreamType.AUDIO) { - val stream = - RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) - if (stream?.language != null) { - streamChoiceService.saveSeriesTrackChoice( - item.data, - stream.type, - stream, - ) - } - } else if (type == MediaStreamType.SUBTITLE) { - if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.saveDisabledSeriesTrackChoice( - item.data, - MediaStreamType.SUBTITLE, - ) - } else if (trackIndex == TrackIndex.ONLY_FORCED) { - streamChoiceService.saveOnlyForcedSeriesTrackChoice( - item.data, - MediaStreamType.SUBTITLE, - ) - } else { - val stream = - RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) - if (stream?.language != null) { - streamChoiceService.saveSeriesTrackChoice( - item.data, - stream.type, - stream, - ) - } - } - } - } - toSave - } + Timber.v("Saving track selection %s", toSave) + saveItemPlayback(toSave) + } /** * Saves the [ItemPlayback] into the database, returning the same object with the rowId updated if needed @@ -229,7 +113,7 @@ class ItemPlaybackRepository val toSave = if (itemPlayback.userId < 0) { val userRowId = - serverRepository.currentUser + serverRepository.currentUser.value ?.rowId ?.takeIf { it >= 0 } ?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user") @@ -240,67 +124,13 @@ class ItemPlaybackRepository val id = itemPlaybackDao.saveItem(toSave) 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, - ): ItemTrackModification? = - serverRepository.currentUser?.rowId?.let { userId -> - itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) - } - - /** - * Create and save a [ItemTrackModification] via [ItemPlaybackDao.saveItem] - */ - suspend fun saveTrackModifications( - itemId: UUID, - trackIndex: Int, - delay: Duration, - ) { - serverRepository.currentUser?.rowId?.let { userId -> - Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay) - itemPlaybackDao.saveItem( - ItemTrackModification( - userId, - itemId, - trackIndex, - delay.inWholeMilliseconds, - ), - ) - } - } - - /** - * Clear the user's source/stream choices for an item - */ - suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { - Timber.d("deleteChosenStreams: %s", chosenStreams) - val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() - if (stc.isNotEmpty()) { - Timber.d("Deleting %s", stc) - seriesTrackChoiceDao.delete(stc) - } - chosenStreams?.itemPlayback?.let { - Timber.d("Deleting %s", it) - itemPlaybackDao.deleteItem(it) - } - } } -/** - * Info about which media source and streams the user/app has chosen for playback - */ data class ChosenStreams( - val itemPlayback: ItemPlayback?, - val audioStc: List<SeriesTrackChoice>, - val subtitleStc: List<SeriesTrackChoice>, + val itemPlayback: ItemPlayback, val itemId: UUID, - val source: MediaSourceInfo, - val videoStream: MediaStream?, - val audioStream: StreamChoiceResult, - val subtitleStream: StreamChoiceResult, + val sourceId: UUID?, + val audioStream: MediaStream?, + val subtitleStream: MediaStream?, val subtitlesDisabled: Boolean, ) 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 f72eb3b7..e44db6f1 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,7 +9,6 @@ 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 @@ -28,7 +27,7 @@ interface JellyfinServerDao { } } - @Insert(onConflict = OnConflictStrategy.IGNORE) + @Insert(onConflict = OnConflictStrategy.Companion.IGNORE) fun addUser(user: JellyfinUser): Long @Update @@ -57,12 +56,6 @@ 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/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt index 524281cb..0124ac3f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -7,7 +7,6 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.ui.toServerString -import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -28,12 +27,6 @@ interface LibraryDisplayInfoDao { itemId: String, ): LibraryDisplayInfo? - @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") - fun getItemAsFlow( - userId: Int, - itemId: String, - ): Flow<LibraryDisplayInfo?> - @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveItem(item: LibraryDisplayInfo): Long diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt deleted file mode 100644 index 5139fb73..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.github.damontecres.wholphin.data - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import com.github.damontecres.wholphin.data.MostRecentServer.None -import com.github.damontecres.wholphin.data.MostRecentServer.Server -import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.toServerString -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import java.util.UUID - -/** - * Represents the status of the most recently used server - * - * This can be saved/retrieved from a [MostRecentServerProvider] - */ -sealed interface MostRecentServer { - /** - * There is no recently used server - */ - data object None : MostRecentServer - - /** - * The most recently used server without a recent user - */ - data class Server( - val serverId: UUID, - val serverUrl: String, - ) : MostRecentServer - - /** - * Most recently used server and user - */ - data class ServerAndUser( - val serverId: UUID, - val serverUrl: String, - val userId: UUID, - val accessToken: String, - ) : MostRecentServer { - override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)" - } - - companion object { - fun from(current: CurrentUser?): MostRecentServer = - if (current != null && current.user.accessToken.isNotNullOrBlank()) { - ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken) - } else if (current != null) { - Server(current.server.id, current.server.url) - } else { - None - } - } -} - -interface MostRecentServerProvider { - /** - * Get the [MostRecentServer] from this provider - */ - fun get(): MostRecentServer - - /** - * Save the [CurrentUser] as the most recent server - */ - suspend fun save(current: CurrentUser) - - /** - * Clear the most recent server and user - */ - suspend fun clear() - - /** - * CLear the most recent user, leaving the server - */ - suspend fun clearUser() -} - -/** - * A [MostRecentServerProvider] that stores in [SharedPreferences] - */ -class MostRecentServerSharedPreferences( - context: Context, -) : MostRecentServerProvider { - private val prefs = getServerSharedPreferences(context) - - override fun get(): MostRecentServer { - val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() - val serverUrl = prefs.getString(SERVER_URL_KEY, null) - val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull() - val accessToken = prefs.getString(USER_TOKEN_KEY, null) - return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) { - ServerAndUser(serverId, serverUrl, userId, accessToken) - } else if (serverId != null && serverUrl.isNotNullOrBlank()) { - Server(serverId, serverUrl) - } else { - None - } - } - - override suspend fun save(current: CurrentUser) { - prefs.edit(true) { - putString(SERVER_ID_KEY, current.server.id.toServerString()) - putString(SERVER_URL_KEY, current.server.url) - putString(USER_ID_KEY, current.user.id.toServerString()) - putString(USER_TOKEN_KEY, current.user.accessToken) - } - } - - override suspend fun clear() { - prefs.edit(true) { - remove(SERVER_ID_KEY) - remove(SERVER_URL_KEY) - remove(USER_ID_KEY) - remove(USER_TOKEN_KEY) - } - } - - override suspend fun clearUser() { - prefs.edit(true) { - remove(USER_ID_KEY) - remove(USER_TOKEN_KEY) - } - } - - private fun getServerSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "${context.packageName}_server", - Context.MODE_PRIVATE, - ) - - companion object { - private const val SERVER_ID_KEY = "current.server.id" - private const val SERVER_URL_KEY = "current.server.url" - private const val USER_ID_KEY = "current.user.id" - private const val USER_TOKEN_KEY = "current.user.accessToken" - } -} - -/** - * The result of restoring a session - */ -sealed interface RestoredSession { - val destination: SetupDestination - - /** - * Session could not be restored - */ - data object None : RestoredSession { - override val destination: SetupDestination - get() = SetupDestination.ServerList - } - - /** - * Only the server should be restored, so a user still need to be chosen - */ - data class ServerOnly( - val server: JellyfinServer, - ) : RestoredSession { - override val destination: SetupDestination - get() = SetupDestination.UserList(server) - } - - /** - * Session was restored successfully - */ - data class Success( - val currentUser: CurrentUser, - ) : RestoredSession { - override val destination: SetupDestination - get() = SetupDestination.AppContent(currentUser) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt new file mode 100644 index 00000000..ba070116 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.data + +import android.content.Context +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.util.supportedCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerItemRepository + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + ) { + suspend fun getNavDrawerItems(): List<NavDrawerItem> { + val user = serverRepository.currentUser.value + val tvAccess = + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvAccess ?: false + val userViews = + api.userViewsApi + .getUserViews(userId = user?.id) + .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = user?.id) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } + + val builtins = listOf(NavDrawerItem.Favorites) + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + val destination = + if (it.id in recordingFolders) { + Destination.Recordings(it.id) + } else { + BaseItem.from(it, api).destination() + } + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = destination, + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + return builtins + libraries + } + + suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> { + val user = serverRepository.currentUser.value + val navDrawerPins = + user + ?.let { + serverPreferencesDao.getNavDrawerPinnedItems(it) + }.orEmpty() + val filtered = items.filter { navDrawerPins.isPinned(it.id) } + if (items.size != filtered.size) { + // Some were filtered out, check if should include More + if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { + return filtered + listOf(NavDrawerItem.More) + } + } + return filtered + } + } + +fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt deleted file mode 100644 index 0fa9f0ee..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.damontecres.wholphin.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.github.damontecres.wholphin.data.model.PlaybackEffect -import org.jellyfin.sdk.model.api.BaseItemKind -import java.util.UUID - -@Dao -interface PlaybackEffectDao { - @Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type") - suspend fun getPlaybackEffect( - jellyfinUserRowId: Int, - itemId: UUID, - type: BaseItemKind, - ): PlaybackEffect? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(playbackEffect: PlaybackEffect) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt deleted file mode 100644 index 06835381..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.damontecres.wholphin.data - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice -import java.util.UUID - -@Deprecated("Use SeriesTrackChoiceDao") -@Dao -interface PlaybackLanguageChoiceDao { - @Deprecated("Use SeriesTrackChoiceDao") - @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") - suspend fun get( - userId: Int, - seriesId: UUID, - ): PlaybackLanguageChoice? - - @Deprecated("Use SeriesTrackChoiceDao") - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun save(plc: PlaybackLanguageChoice): Long - - @Deprecated("Use SeriesTrackChoiceDao") - @Delete - fun delete(plc: PlaybackLanguageChoice) - - @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset") - suspend fun getAll( - limit: Int, - offset: Int, - ): List<PlaybackLanguageChoice> -} 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 deleted file mode 100644 index 13398228..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/RememberedTabDao.kt +++ /dev/null @@ -1,22 +0,0 @@ -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/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt deleted file mode 100644 index 3a2c325c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.damontecres.wholphin.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Transaction -import androidx.room.Update -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrServerUsers -import com.github.damontecres.wholphin.data.model.SeerrUser - -@Dao -interface SeerrServerDao { - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun addServer(server: SeerrServer): Long - - @Update - suspend fun updateServer(server: SeerrServer): Int - - @Transaction - suspend fun addOrUpdateServer(server: SeerrServer) { - val result = addServer(server) - if (result == -1L) { - updateServer(server) - } - } - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun addUser(user: SeerrUser): Long - - suspend fun updateUser(user: SeerrUser) = addUser(user) - - @Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") - suspend fun getUser( - serverId: Int, - jellyfinUserRowId: Int, - ): SeerrUser? - - @Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId") - suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser> - - @Query("DELETE FROM seerr_servers WHERE id = :serverId") - suspend fun deleteServer(serverId: Int) - - @Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") - suspend fun deleteUser( - serverId: Int, - jellyfinUserRowId: Int, - ): Int - - suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) - - @Transaction - @Query("SELECT * FROM seerr_servers") - suspend fun getServers(): List<SeerrServerUsers> - - @Transaction - @Query("SELECT * FROM seerr_servers WHERE id = :serverId") - suspend fun getServer(serverId: Int): SeerrServerUsers? - - @Transaction - @Query("SELECT * FROM seerr_servers WHERE url = :url") - suspend fun getServer(url: String): SeerrServerUsers? -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt deleted file mode 100644 index b3126723..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.damontecres.wholphin.data - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import java.util.UUID - -@Dao -interface SeriesTrackChoiceDao { - @Query( - "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC", - ) - suspend fun get( - userId: Int, - seasonId: UUID, - seriesId: UUID, - type: SeriesTrackChoiceType, - ): List<SeriesTrackChoice> - - @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId") - suspend fun getBySeasonId( - userId: Int, - seasonId: UUID, - type: SeriesTrackChoiceType, - ): List<SeriesTrackChoice> - - @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId") - suspend fun getBySeriesId( - userId: Int, - seriesId: UUID, - type: SeriesTrackChoiceType, - ): List<SeriesTrackChoice> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun save(stc: SeriesTrackChoice): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun save(stc: List<SeriesTrackChoice>): List<Long> - - @Delete - suspend fun delete(stc: List<SeriesTrackChoice>) - - @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId") - suspend fun getAll(userId: Int): List<SeriesTrackChoice> - - @Query("SELECT count(*) from SeriesTrackChoice") - suspend fun count(): Int -} 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 2c24c8f3..a381464d 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 @@ -1,34 +1,28 @@ package com.github.damontecres.wholphin.data import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.datastore.core.DataStore +import androidx.lifecycle.LiveData +import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.time.ZonedDateTime import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -44,35 +38,16 @@ class ServerRepository val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, - @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, - private val mostRecentServerProvider: MostRecentServerProvider, + val userPreferencesDataStore: DataStore<AppPreferences>, ) { - private var _current = MutableStateFlow<CurrentUser?>(null) - val current: StateFlow<CurrentUser?> = _current + private val sharedPreferences = getServerSharedPreferences(context) - private var _currentUserDto = MutableStateFlow<ServerUserConfig?>(null) - val currentUserDto: ServerUserConfig? get() = _currentUserDto.value - val currentUserDtoFlow: StateFlow<ServerUserConfig?> get() = _currentUserDto + private var _current = EqualityMutableLiveData<CurrentUser?>(null) + val current: LiveData<CurrentUser?> = _current - val currentServer: JellyfinServer? get() = _current.value?.server - val currentServerFlow: Flow<JellyfinServer?> get() = _current.map { it?.server } - val currentUser: JellyfinUser? get() = _current.value?.user - - @OptIn(ExperimentalCoroutinesApi::class) - val currentUserFlow: Flow<JellyfinUser?> - get() = - _current - .flatMapLatest { user -> - if (user?.user != null) { - serverDao - .getUserFlow(user.user.serverId, user.user.id) - .distinctUntilChanged() - } else { - flow { emit(null) } - } - } - - val serverPluginInstalled = MutableStateFlow<Boolean>(false) + val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server } + val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user } + val currentUserDto: LiveData<UserDto?> get() = _current.map { it?.userDto } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -80,138 +55,82 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) - _current.value = null - mostRecentServerProvider.clearUser() + _current.setValueOnMain(null) } /** - * Saves the server & user to the app database and updates the [ApiClient] to use this server & user + * Saves the server & User to the app database and updates the [ApiClient] to use this server & user */ suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, - ): CurrentUser = - withContext(ioDispatcher) { - if (server.id != user.serverId) { - throw IllegalStateException("User is not part of the server") - } - Timber.v("Changing user to ${user.name} on ${server.url}") - apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto by apiClient.userApi.getCurrentUser() - val updatedServer = - try { - val sysInfo by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = sysInfo.serverName, version = sysInfo.version) - } catch (ex: Exception) { - Timber.w(ex, "Exception fetching public system info") - server - } - var updatedUser = - user.copy( - id = userDto.id, - name = userDto.name, - ) - serverDao.addOrUpdateServer(updatedServer) - updatedUser = serverDao.addOrUpdateUser(updatedUser) - - val currentUser = CurrentUser(updatedServer, updatedUser) - mostRecentServerProvider.save(currentUser) - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) - return@withContext currentUser + ) = withContext(Dispatchers.IO) { + if (server.id != user.serverId) { + throw IllegalStateException("User is not part of the server") } - - /** - * Try to restore the most recent session - * - * Does not check if auto sign-in is enabled, but does check if the user profile is protected - */ - suspend fun restoreLastSession(): RestoredSession { - val recentServer = mostRecentServerProvider.get() - Timber.d("restoreLastSession: recentServer=%s", recentServer) - val result = - when (recentServer) { - MostRecentServer.None -> { - RestoredSession.None - } - - is MostRecentServer.Server -> { - val server = serverDao.getServer(recentServer.serverId) - if (server != null) { - RestoredSession.ServerOnly(server.server) - } else { - RestoredSession.None - } - } - - is MostRecentServer.ServerAndUser -> { - val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId) - if (currentUser != null) { - if (currentUser.user.isProtected) { - RestoredSession.ServerOnly(currentUser.server) - } else { - RestoredSession.Success(currentUser) - } - } else { - getMostRecentServerInternal(recentServer) - } - } + Timber.v("Changing user to ${user.name} on ${server.url}") + apiClient.update(baseUrl = server.url, accessToken = user.accessToken) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName, version = sysInfo.version) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server } - return result - } - - /** - * Get the most recently used server, if any - */ - suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get()) - - suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession { - val recentServer = recentServer ?: mostRecentServerProvider.get() - val serverId = - when (recentServer) { - MostRecentServer.None -> null - is MostRecentServer.Server -> recentServer.serverId - is MostRecentServer.ServerAndUser -> recentServer.serverId - } - val server = serverId?.let { serverDao.getServer(serverId)?.server } - return if (server != null) { - RestoredSession.ServerOnly(server) - } else { - RestoredSession.None + var updatedUser = + user.copy( + id = userDto.id, + name = userDto.name, + ) + serverDao.addOrUpdateServer(updatedServer) + updatedUser = serverDao.addOrUpdateUser(updatedUser) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = updatedServer.id.toServerString() + currentUserId = updatedUser.id.toServerString() + }.build() + } + withContext(Dispatchers.Main) { + _current.value = CurrentUser(updatedServer, updatedUser, userDto) + } + sharedPreferences.edit(true) { + putString(SERVER_URL_KEY, updatedServer.url) + putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } } /** - * Try to change to the given server & user. If the user's profile is protected, returns null - * - * @return the server/user or null if the session could not be restored + * Restores a session for the given server & user such as when the app reopens */ - suspend fun tryChangeUser( + suspend fun restoreSession( serverId: UUID?, userId: UUID?, - ): CurrentUser? = - withContext(ioDispatcher) { - return@withContext if (serverId == null || userId == null) { - _current.value = null - null - } else { - val serverAndUsers = serverDao.getServer(serverId) - if (serverAndUsers != null) { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null && !user.isProtected) { - changeUser(serverAndUsers.server, user) - } else { - null - } - } else { - null - } + ): Boolean { + if (serverId == null || userId == null) { + _current.setValueOnMain(null) + return false + } + val serverAndUsers = + withContext(Dispatchers.IO) { + serverDao.getServer(serverId) + } + if (serverAndUsers != null) { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null) { + changeUser(serverAndUsers.server, user) + return true } } + return false + } /** * Given a successful [AuthenticationResult], switch to the user that just authenticated @@ -219,8 +138,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - existingUser: JellyfinUser?, - ) = withContext(ioDispatcher) { + ) = withContext(Dispatchers.IO) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -236,27 +154,15 @@ class ServerRepository if (server != null) { val user = authedUser?.let { - if (existingUser != null) { - Timber.d("Re-using existing user") - existingUser.copy( - // If the server authenticated via the server, always remove the PIN - pin = null, - accessToken = accessToken, - lastUsed = ZonedDateTime.now(), - ) - } else { - Timber.d("Creating new user") - JellyfinUser( - id = it.id, - name = it.name, - serverId = server.id, - accessToken = accessToken, - lastUsed = ZonedDateTime.now(), - ) - } + JellyfinUser( + id = it.id, + name = it.name, + serverId = server.id, + accessToken = accessToken, + ) } if (user != null) { - return@withContext changeUser(server, user) + changeUser(server, user) } else { throw IllegalArgumentException("Authentication result's user was null") } @@ -269,85 +175,70 @@ class ServerRepository } suspend fun removeUser(user: JellyfinUser) { - if (current.value?.user?.id == user.id) { - withContext(WholphinDispatchers.Main) { + if (currentUser.value?.id == user.id) { + withContext(Dispatchers.Main) { _current.value = null } - mostRecentServerProvider.clearUser() + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentUserId = "" + }.build() + } apiClient.update(accessToken = null) } - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { serverDao.deleteUser(user.serverId, user.id) } } suspend fun removeServer(server: JellyfinServer) { - if (current.value?.server?.id == server.id) { - withContext(WholphinDispatchers.Main) { + if (currentServer.value?.id == server.id) { + withContext(Dispatchers.Main) { _current.value = null } - mostRecentServerProvider.clear() + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = "" + currentUserId = "" + }.build() + } apiClient.update(baseUrl = null, accessToken = null) } - withContext(ioDispatcher) { + withContext(Dispatchers.IO) { serverDao.deleteServer(server.id) } } suspend fun switchServerOrUser() { - mostRecentServerProvider.clear() - } - - suspend fun updateUserAuth( - user: JellyfinUser, - pin: String?, - requireLogin: Boolean, - ) { - val newUser = user.copy(pin = pin, requireLogin = requireLogin) - val updatedUser = serverDao.addOrUpdateUser(newUser) - val cur = current.value - if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) { - // Updating current user, so push out the change - current.value?.let { - val newCurrent = it.copy(user = updatedUser) - _current.value = newCurrent - } + apiClient.update(baseUrl = null, accessToken = null) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = "" + currentUserId = "" + }.build() } } - suspend fun authorizeQuickConnect(code: String): Boolean = - withContext(ioDispatcher) { - val userId = current.value?.user?.id - if (userId == null) { - Timber.e("No user logged in for Quick Connect authorization") - throw IllegalStateException("Must be logged in to authorize Quick Connect") - } - val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId) - response.content - } + companion object { + fun getServerSharedPreferences(context: Context): SharedPreferences = + context.getSharedPreferences( + "${context.packageName}_server", + Context.MODE_PRIVATE, + ) - /** - * Update [currentUserDto] by querying the server - */ - suspend fun updateUserDto() { - val userDto by apiClient.userApi.getCurrentUser() - updateUserDto(userDto) - } - - /** - * Update [currentUserDto] with the specified [UserDto] - * - * This will only update if the [UserDto] is for the [currentUser] - */ - fun updateUserDto(userDto: UserDto) { - _currentUserDto.update { - if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it - } + const val SERVER_URL_KEY = "current.server" + const val ACCESS_TOKEN_KEY = "current.accessToken" } } -@Serializable data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, + val userDto: UserDto, ) 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 deleted file mode 100644 index 446a64c0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/DiscoverFilter.kt +++ /dev/null @@ -1,287 +0,0 @@ -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 b92d251f..92dbecf3 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 @@ -17,19 +17,6 @@ val DefaultFilterOptions = DecadeFilter, ) -val DefaultTvFilterOptions = - listOf( - PlayedFilter, - FavoriteFilter, - GenreFilter, - StudioFilter, - CommunityRatingFilter, - OfficialRatingFilter, - VideoTypeFilter, - YearFilter, - DecadeFilter, - ) - val DefaultForFavoritesFilterOptions = listOf( PlayedFilter, @@ -45,7 +32,6 @@ val DefaultForGenresFilterOptions = listOf( PlayedFilter, FavoriteFilter, - StudioFilter, CommunityRatingFilter, OfficialRatingFilter, VideoTypeFilter, @@ -53,53 +39,20 @@ val DefaultForGenresFilterOptions = DecadeFilter, ) -val DefaultForStudiosFilterOptions = - listOf( - PlayedFilter, - FavoriteFilter, - GenreFilter, - CommunityRatingFilter, - OfficialRatingFilter, - VideoTypeFilter, - YearFilter, - DecadeFilter, - ) - -val DefaultPlaylistItemsOptions = - listOf( - PlayedFilter, - FavoriteFilter, - CommunityRatingFilter, - OfficialRatingFilter, - VideoTypeFilter, - YearFilter, - DecadeFilter, - ) - -/** - * Abstract way to filter data - */ -sealed interface FilterBy<FilterType, DataType> { +sealed interface ItemFilterBy<T> { @get:StringRes val stringRes: Int val supportMultiple: Boolean - fun get(filter: FilterType): DataType? + fun get(filter: GetItemsFilter): T? fun set( - value: DataType?, - filter: FilterType, - ): FilterType + value: T?, + filter: GetItemsFilter, + ): GetItemsFilter } -/** - * 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 @@ -214,16 +167,3 @@ data object CommunityRatingFilter : ItemFilterBy<Int> { filter: GetItemsFilter, ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) } - -data object StudioFilter : ItemFilterBy<List<UUID>> { - override val stringRes: Int = R.string.studios - - override val supportMultiple: Boolean = true - - override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios - - override fun set( - value: List<UUID>?, - filter: GetItemsFilter, - ): GetItemsFilter = filter.copy(studios = value) -} 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 deleted file mode 100644 index 6b30e9ff..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/SearchFilter.kt +++ /dev/null @@ -1,27 +0,0 @@ -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/AudioItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt deleted file mode 100644 index cf8ce4d0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/AudioItem.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import androidx.compose.runtime.Stable -import org.jellyfin.sdk.model.extensions.ticks -import java.util.UUID -import kotlin.time.Duration - -/** - * Represents audio or a song as a stripped down [BaseItem] since there may be a lot of these created. - * - * Typically added to a MediaItem as the tag for reference later - * - * The "key" can be used by a Compose LazyList key function as it will uniquely identify this particular - * audio even if the same song is added to the queue multiple times - */ -@Stable -data class AudioItem( - val key: Long = keyTracker++, - val id: UUID, - val albumId: UUID?, - val artistId: UUID?, - val title: String?, - val albumTitle: String?, - val artistNames: String?, - val runtime: Duration?, - val imageUrl: String?, - val hasLyrics: Boolean, -) { - companion object { - private var keyTracker = 0L - - fun from( - item: BaseItem, - imageUrl: String?, - ): AudioItem = - AudioItem( - id = item.id, - albumId = item.data.albumId, - artistId = - item.data.artistItems - ?.firstOrNull() - ?.id, - title = item.title, - albumTitle = item.data.album, - artistNames = item.data.artists?.joinToString(", "), - runtime = item.data.runTimeTicks?.ticks, - imageUrl = imageUrl, - hasLyrics = item.data.hasLyrics == true, - ) - } -} 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 256ccd65..c6f83be6 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 @@ -1,75 +1,38 @@ package com.github.damontecres.wholphin.data.model -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 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.api.client.extensions.imageApi 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.ImageType import org.jellyfin.sdk.model.extensions.ticks -import java.util.UUID import kotlin.time.Duration -/** - * Wrapper for [BaseItemDto] with shortcuts for various UI elements - */ @Serializable -@Stable data class BaseItem( val data: BaseItemDto, - val useSeriesForPrimary: Boolean = false, - val imageUrlOverride: String? = null, - val destinationOverride: Destination? = null, -) : CardGridItem { + val imageUrl: String?, + val backdropImageUrl: String? = null, + val logoImageUrl: String? = null, +) { val id get() = data.id - override val gridId get() = id.toString() - - override val playable: Boolean - get() = type.playable - - override val sortName: String - get() = data.alphabetSortName - val type get() = data.type val name get() = data.name val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name - 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(" - ") - BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ") - else -> data.productionYear?.toString() - } - } + val subtitle + get() = + if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() val subtitleLong: String? by lazy { if (type == BaseItemKind.EPISODE) { @@ -77,17 +40,14 @@ data class BaseItem( add(data.seasonEpisodePadded) add(data.name) add(data.premiereDate?.let { formatDateTime(it) }) - }.joinNotBlank(" - ") + }.filterNotNull().joinToString(" - ") } else { data.productionYear?.toString() } } - val canDelete: Boolean get() = data.canDelete == true - - val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } - - val indexNumber get() = data.indexNumber + @Transient + val indexNumber = data.indexNumber ?: dateAsIndex() val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -97,145 +57,6 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - 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 - */ - @Transient - val ui = - BaseItemUi( - episodeCornerText = - if (type == BaseItemKind.EPISODE) { - data.indexNumber?.let { formatEpisodeNumber(it) } - ?: data.premiereDate?.let(::formatDateTime) - } else { - null - }, - episodeUnplayedCornerText = - when (type) { - BaseItemKind.EPISODE -> { - data.indexNumber?.let { formatEpisodeNumber(it) } - } - - BaseItemKind.SERIES, - BaseItemKind.SEASON, - BaseItemKind.BOX_SET, - -> { - data.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) } - } - - else -> { - null - } - }, - quickDetails = - QuickDetailsData( - basic = - buildAnnotatedString { - val details = - buildList { - when (type) { - BaseItemKind.EPISODE -> { - data.seasonEpisode?.let(::add) - data.premiereDate?.let { - add( - getDateFormatter().format( - it, - ), - ) - } - } - - BaseItemKind.SEASON -> { - data.childCount?.let { childCount -> - add( - WholphinApplication.instance.resources.getQuantityString( - R.plurals.episodes_count, - childCount, - childCount, - ), - ) - } - data.productionYear?.let { add(it.toString()) } - } - - BaseItemKind.SERIES -> { - data.seriesProductionYears?.let(::add) - } - - BaseItemKind.PHOTO -> { - if (data.productionYear != null) { - add(data.productionYear!!.toString()) - } else if (data.premiereDate != null) { - add(data.premiereDate!!.toLocalDate().toString()) - } - } - - BaseItemKind.BOX_SET -> { - data.productionYear?.let { add(it.toString()) } - data.childCount?.let { add("$it items") } - } - - 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 - ?.takeIf { it > Duration.ZERO } - ?.let { add(WholphinApplication.instance.resources.formatDuration(it)) } - data.timeRemaining - ?.roundMinutes - ?.let { - val resources = WholphinApplication.instance.resources - add( - resources.getString( - R.string.time_left, - resources.formatDuration(it), - ), - ) - } - } - details.forEachIndexed { index, string -> - append(string) - if (index != details.lastIndex) { - dot() - } - } - }, - officialRating = - data.officialRating?.let { - buildAnnotatedString { - dot() - append(it) - } - }, - ), - ) - private fun dateAsIndex(): Int? = data.premiereDate ?.let { @@ -244,170 +65,84 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() - /** - * Convert this [BaseItem] into a [Destination] to navigate to its page in the app - */ - 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) - } + fun destination(): Destination { val result = // Redirect episodes & seasons to their series if possible when (type) { BaseItemKind.EPISODE -> { - val seasonId = data.seasonId - val seriesId = data.seriesId - if (seasonId != null && seriesId != null) { + data.seasonId?.let { seasonId -> Destination.SeriesOverview( - seriesId, + data.seriesId!!, BaseItemKind.SERIES, + this, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } else { - Destination.MediaItem(this) - } + } ?: Destination.MediaItem(id, type, this) } - BaseItemKind.SEASON -> { - val seriesId = data.seriesId - if (seriesId != null) { - Destination.SeriesOverview( - seriesId, - BaseItemKind.SERIES, - SeasonEpisodeIds(id, indexNumber, null, null), - ) - } else { - Destination.MediaItem(this) - } - } - - BaseItemKind.TV_CHANNEL -> { - Destination.Playback( - itemId = id, - positionMs = 0L, + BaseItemKind.SEASON -> + Destination.SeriesOverview( + data.seriesId!!, + BaseItemKind.SERIES, + this, + SeasonEpisodeIds(id, indexNumber, null, null), ) - } - BaseItemKind.PROGRAM -> { - val channelId = data.channelId - if (channelId != null) { - Destination.Playback( - itemId = channelId, - positionMs = 0L, - ) - } else { - Destination.MediaItem(this) - } - } - - BaseItemKind.AUDIO -> { - data.albumId?.let { albumId -> - Destination.MediaItem( - itemId = albumId, - type = BaseItemKind.MUSIC_ALBUM, - initialSongId = id, - ) - } ?: Destination.MediaItem(this) - } - - else -> { - Destination.MediaItem(this) - } + else -> Destination.MediaItem(id, type, this) } return result } companion object { - @Deprecated("Use regular constructor instead") fun from( dto: BaseItemDto, api: ApiClient, useSeriesForPrimary: Boolean = false, - ): BaseItem = - BaseItem( + ): BaseItem { + val backdropImageUrl = + if (dto.type == BaseItemKind.EPISODE) { + val seriesId = dto.seriesId + if (seriesId != null) { + api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP) + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) + } + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) + } + val primaryImageUrl = + if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE) { + val seriesId = dto.seriesId + if (seriesId != null) { + api.imageApi.getItemImageUrl(seriesId, ImageType.PRIMARY) + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) + } + } else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) { + // TODO is this a bad assumption? + null + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY) + } + val logoImageUrl = + if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) { + val seriesId = dto.seriesId + if (seriesId != null) { + api.imageApi.getItemImageUrl(seriesId, ImageType.LOGO) + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO) + } + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO) + } + return BaseItem( dto, - useSeriesForPrimary, + primaryImageUrl, + backdropImageUrl, + logoImageUrl, ) + } } } 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?, - val episodeUnplayedCornerText: String?, - val quickDetails: QuickDetailsData, -) - -data class QuickDetailsData( - val basic: AnnotatedString, - val officialRating: AnnotatedString? = null, -) - -/** - * Create the special [Destination.FilteredCollection] for the given genre information - */ -fun createGenreDestination( - genreId: UUID, - genreName: String, - parentId: UUID, - parentName: String?, - includeItemTypes: List<BaseItemKind>?, - collectionType: CollectionType, -) = Destination.FilteredCollection( - itemId = parentId, - parentType = BaseItemKind.GENRE, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genreName, - parentName, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genreId), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = true, - libraryDisplayInfoIdOverride = "${parentId.toServerString()}_genres", - ), - recursive = true, - collectionType = collectionType, -) - -fun createStudioDestination( - studioId: UUID, - name: String, - parentId: UUID, - parentName: String?, - includeItemTypes: List<BaseItemKind>?, -) = Destination.FilteredCollection( - itemId = parentId, - parentType = BaseItemKind.STUDIO, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - name, - parentName, - ).joinToString(" "), - filter = - GetItemsFilter( - studios = listOf(studioId), - includeItemTypes = includeItemTypes, - ), - 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 71a300fb..1bcacb3f 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,32 +1,36 @@ 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 tag: String?, - val index: Int, + val imageUrl: String?, ) { companion object { - fun fromDto(dto: BaseItemDto): List<Chapter> = + fun fromDto( + dto: BaseItemDto, + api: ApiClient, + ): List<Chapter> = dto.chapters ?.mapIndexed { index, chapter -> Chapter( - itemId = dto.id, - name = chapter.name, - position = chapter.startPositionTicks.ticks, - tag = chapter.imageTag, - index = index, + chapter.name, + chapter.startPositionTicks.ticks, + chapter.imageTag?.let { + api.imageApi.getItemImageUrl( + itemId = dto.id, + imageType = ImageType.CHAPTER, + tag = it, + imageIndex = index, + ) + }, ) - }?.sortedBy { it.position } - .orEmpty() + }.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 deleted file mode 100644 index fe4650a6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ /dev/null @@ -1,123 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -package com.github.damontecres.wholphin.data.model - -import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.LocalDateSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.time.LocalDate -import java.util.UUID - -/** - * The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind] - */ -@Serializable -enum class SeerrItemType( - val baseItemKind: BaseItemKind?, -) { - @SerialName("movie") - MOVIE(BaseItemKind.MOVIE), - - @SerialName("tv") - TV(BaseItemKind.SERIES), - - @SerialName("person") - PERSON(BaseItemKind.PERSON), - - @SerialName("unknown") - UNKNOWN(null), - ; - - companion object { - fun fromString( - str: String?, - fallback: SeerrItemType = UNKNOWN, - ) = when (str) { - "movie" -> MOVIE - "tv" -> TV - "person" -> PERSON - else -> fallback - } - } -} - -/** - * How available is a particular discovered item within the Jellyfin server - */ -@Serializable -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), - BLOCKLISTED(6), - DELETED(7), - ; - - companion object { - fun from(status: Int?) = entries.firstOrNull { it.status == status } - } -} - -@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]. - */ -@Stable -@Serializable -data class DiscoverItem( - val id: Int, - val type: SeerrItemType, - val title: String?, - val subtitle: String?, - val overview: String?, - val availability: SeerrAvailability, - @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() - override val playable: Boolean = false - override val sortName: String get() = title ?: "" - - val destination: Destination - get() { - val jfType = type.baseItemKind - return if (jellyfinItemId != null && jfType != null) { - Destination.MediaItem( - itemId = jellyfinItemId, - type = jfType, - ) - } else { - Destination.DiscoveredItem(this) - } - } -} 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 91228969..99d4d38d 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,33 +9,11 @@ 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.GetAlbumArtistsRequest -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 import java.util.UUID -/** - * Filter for a collection folder - */ -@Serializable -data class CollectionFolderFilter( - val nameOverride: String? = null, - val filter: GetItemsFilter = GetItemsFilter(), - /** - * 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, -) - -/** - * A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters - */ @Serializable data class GetItemsFilter( val favorite: Boolean? = null, @@ -64,7 +42,7 @@ data class GetItemsFilter( } /** - * Clear all the values for the given filters + * Clear all of the values for the given filters */ fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter { var newFilter = this @@ -128,7 +106,6 @@ data class GetItemsFilter( -> null FilterVideoType.BLU_RAY -> VideoType.BLU_RAY - FilterVideoType.DVD -> VideoType.DVD } } @@ -140,41 +117,6 @@ 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)) } - }, - ) - - fun applyTo(req: GetAlbumArtistsRequest) = - 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 - */ fun merge(filter: GetItemsFilter): GetItemsFilter = this.copy( favorite = favorite ?: filter.favorite, @@ -196,5 +138,4 @@ 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 deleted file mode 100644 index 39b82bc8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ /dev/null @@ -1,327 +0,0 @@ -@file:UseSerializers( - UUIDSerializer::class, - DateTimeSerializer::class, -) - -package com.github.damontecres.wholphin.data.model - -import com.github.damontecres.wholphin.preferences.PrefContentScale -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.components.ViewOptionImageType -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.serializer.DateTimeSerializer -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID - -@Serializable -sealed interface HomeRowConfig { - val viewOptions: HomeRowViewOptions - - fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig - - /** - * Continue watching media that the user has started but not finished - */ - @Serializable - @SerialName("ContinueWatching") - data class ContinueWatching( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions) - } - - /** - * Next up row for next episodes in a series the user has started - */ - @Serializable - @SerialName("NextUp") - data class NextUp( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions) - } - - /** - * Combined [ContinueWatching] and [NextUp] - */ - @Serializable - @SerialName("ContinueWatchingCombined") - data class ContinueWatchingCombined( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions) - } - - /** - * Media recently added to a library - */ - @Serializable - @SerialName("RecentlyAdded") - data class RecentlyAdded( - val parentId: UUID, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions) - } - - /** - * Media recently released (premiere date) in a library - */ - @Serializable - @SerialName("RecentlyReleased") - data class RecentlyReleased( - val parentId: UUID, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions) - } - - /** - * Row of a genres in a library - */ - @Serializable - @SerialName("Genres") - data class Genres( - val parentId: UUID, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) - } - - /** - * Row of a studios in a library - */ - @Serializable - @SerialName("Studios") - data class Studios( - val parentId: UUID, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions) - } - - /** - * Favorites for a specific type - */ - @Serializable - @SerialName("Favorite") - data class Favorite( - val kind: BaseItemKind, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) - } - - /** - * Currently recording - */ - @Serializable - @SerialName("Recordings") - data class Recordings( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions) - } - - /** - * Programs on now/recommended - */ - @Serializable - @SerialName("TvPrograms") - data class TvPrograms( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) - } - - /** - * Live TV channels - */ - @Serializable - @SerialName("TvChannels") - data class TvChannels( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) - } - - /** - * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID - */ - @Serializable - @SerialName("Suggestions") - data class Suggestions( - val parentId: UUID, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions) - } - - /** - * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting - */ - @Serializable - @SerialName("ByParent") - data class ByParent( - val parentId: UUID, - val recursive: Boolean = false, - val sort: SortAndDirection? = null, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions) - } - - /** - * An arbitrary [HomeGetItemsFilter] allowing to query for most anything - */ - @Serializable - @SerialName("GetItems") - data class GetItems( - val name: String, - val filter: HomeGetItemsFilter, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) - } - - /** - * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult<BaseItemDto>. - * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing - * specific knowledge of them. - * - * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin - * config is persisted as XML, which doesn't support IDictionary. - */ - @Serializable - @SerialName("CustomEndpoint") - data class CustomEndpoint( - val endpoint: String, - val title: String, - val headers: List<KeyValueEntry>? = null, - val query: List<KeyValueEntry>? = null, - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - ) : HomeRowConfig { - override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions) - } -} - -@Serializable -data class KeyValueEntry( - val key: String, - val value: String, -) - -/** - * Root class for home page settings - * - * Contains the list of rows and a version - */ -@Serializable -@SerialName("HomePageSettings") -data class HomePageSettings( - val rows: List<HomeRowConfig>, - val version: Int, -) { - companion object { - val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION) - } -} - -/** - * This is the max version supported by this version of the app - */ -const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 - -/** - * View options for displaying a row - * - * Allows for changing things like height or aspect ratio - */ -@Serializable -data class HomeRowViewOptions( - val heightDp: Int = Cards.HEIGHT_2X3_DP, - val spacing: Int = 16, - val contentScale: PrefContentScale = PrefContentScale.FILL, - val aspectRatio: AspectRatio = AspectRatio.TALL, - val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, - val showTitles: Boolean = false, - val useSeries: Boolean = true, - val episodeContentScale: PrefContentScale = PrefContentScale.FILL, - val episodeAspectRatio: AspectRatio = AspectRatio.TALL, - val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, -) { - companion object { - val genreDefault = - HomeRowViewOptions( - heightDp = Cards.HEIGHT_EPISODE, - aspectRatio = AspectRatio.WIDE, - ) - - val liveTvDefault = - HomeRowViewOptions( - heightDp = Cards.HEIGHT_LIVE_TV, - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.FIT, - ) - } -} - -/** - * Subset of [GetItemsFilter] - */ -@Serializable -data class HomeGetItemsFilter( - val sortBy: List<ItemSortBy>? = null, - val sortOrder: List<SortOrder>? = null, - val ids: List<UUID>? = null, - val minCommunityRating: Double? = null, - val minCriticRating: Double? = null, - val recursive: Boolean? = null, - val searchTerm: String? = null, - val parentId: UUID? = null, - val includeItemTypes: List<BaseItemKind>? = null, - val isFavorite: Boolean? = null, - val isPlayed: Boolean? = null, - val officialRatings: Collection<String>? = null, - val tags: List<String>? = null, - val years: List<Int>? = null, - val personIds: List<UUID>? = null, - val minOfficialRating: String? = null, - val hasOfficialRating: Boolean? = null, - val studioIds: List<UUID>? = null, - val genreIds: List<UUID>? = null, - val minPremiereDate: LocalDateTime? = null, - val maxPremiereDate: LocalDateTime? = null, -) { - fun asGetItemsRequest() = - GetItemsRequest( - sortBy = sortBy, - sortOrder = sortOrder, - ids = ids, - minCommunityRating = minCommunityRating, - minCriticRating = minCriticRating, - recursive = recursive, - searchTerm = searchTerm, - parentId = parentId, - includeItemTypes = includeItemTypes, - isFavorite = isFavorite, - isPlayed = isPlayed, - officialRatings = officialRatings, - tags = tags, - years = years, - personIds = personIds, - minOfficialRating = minOfficialRating, - hasOfficialRating = hasOfficialRating, - studioIds = studioIds, - genreIds = genreIds, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index b31261fd..10bbaea7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -7,16 +7,21 @@ import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemDto +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.SubtitlePlaybackMode import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID -/** - * Store the media source and audio/subtitle tracks chosen for a specific media item - * - */ @Entity( foreignKeys = [ ForeignKey( @@ -48,6 +53,119 @@ data class ItemPlayback( val subtitleIndexEnabled = subtitleIndex >= 0 } +/** + * Returns the [MediaSourceInfo] with the highest video resolution + */ +fun chooseSource(sources: List<MediaSourceInfo>?) = + sources?.letNotEmpty { sources -> + val result = + sources.maxByOrNull { s -> + s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video -> + (video.width ?: 0) * (video.height ?: 0) + } ?: 0 + } + result + } + +/** + * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution + */ +fun chooseSource( + dto: BaseItemDto, + itemPlayback: ItemPlayback?, +) = itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } } + ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull() + +fun chooseStream( + dto: BaseItemDto, + itemPlayback: ItemPlayback?, + type: MediaStreamType, + prefs: UserPreferences, +): MediaStream? { + val source = chooseSource(dto, itemPlayback) + return source?.mediaStreams?.letNotEmpty { streams -> + val candidates = streams.filter { it.type == type } + when (type) { + MediaStreamType.AUDIO -> chooseAudioStream(candidates, itemPlayback, prefs) + MediaStreamType.SUBTITLE -> chooseSubtitleStream(candidates, itemPlayback, prefs) + else -> candidates.firstOrNull() + } + } +} + +fun chooseAudioStream( + candidates: List<MediaStream>, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, +): MediaStream? = + if (itemPlayback?.audioIndexEnabled == true) { + candidates.firstOrNull { it.index == itemPlayback.audioIndex } + } else { + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = prefs.userConfig.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + } + +fun chooseSubtitleStream( + candidates: List<MediaStream>, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, +): MediaStream? { + if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { + return null + } else if (itemPlayback?.subtitleIndexEnabled == true) { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } + } else { + val subtitleLanguage = prefs.userConfig.subtitleLanguagePreference + return when (prefs.userConfig.subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + candidates.firstOrNull { it.language == subtitleLanguage } + } else { + candidates.firstOrNull() + } + } + + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + + SubtitlePlaybackMode.SMART -> { + val audioLanguage = prefs.userConfig.audioLanguagePreference + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + candidates.firstOrNull { it.language == subtitleLanguage } + } else { + null + } + } + + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + candidates.firstOrNull { it.isDefault && it.isForced } + ?: candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull { it.isForced } + ) + } + + SubtitlePlaybackMode.NONE -> null + } + } +} + object TrackIndex { /** * The user has not explicitly specified a track to use @@ -58,9 +176,4 @@ object TrackIndex { * The user has explicitly disabled the tracks (eg turned off subtitles) */ const val DISABLED = -2 - - /** - * The user wants only forced subtitles (for foreign language dialogue, signs, etc.) - */ - const val ONLY_FORCED = -3 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt deleted file mode 100644 index 3888e438..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemTrackModification.kt +++ /dev/null @@ -1,35 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -package com.github.damontecres.wholphin.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID - -/** - * Store modifications to an audio/subtitle track in a media item - * - * For example, the subtitle delay - */ -@Entity( - foreignKeys = [ - ForeignKey( - entity = JellyfinUser::class, - parentColumns = arrayOf("rowId"), - childColumns = arrayOf("userId"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - ), - ], - primaryKeys = ["userId", "itemId", "trackIndex"], -) -@Serializable -data class ItemTrackModification( - val userId: Int, - val itemId: UUID, - val trackIndex: Int, - val delayMs: Long, -) 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 6c5a2a58..e7933992 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 @@ -1,4 +1,4 @@ -@file:UseSerializers(UUIDSerializer::class, ZonedDateTimeSerializer::class) +@file:UseSerializers(UUIDSerializer::class) package com.github.damontecres.wholphin.data.model @@ -10,21 +10,13 @@ import androidx.room.Ignore 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.services.HomePageSettingsSource import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.time.ZonedDateTime import java.util.UUID -/** - * Represents a Jellyfin server - */ @Entity(tableName = "servers") @Serializable data class JellyfinServer( @@ -32,15 +24,11 @@ data class JellyfinServer( val name: String?, val url: String, val version: String?, - val lastUsed: ZonedDateTime? = null, ) { @get:Ignore val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) } } -/** - * Represents a Jellyfin user for a particular server - */ @Entity( tableName = "users", foreignKeys = [ @@ -53,7 +41,6 @@ data class JellyfinServer( ], indices = [Index("id", "serverId", unique = true)], ) -@Serializable data class JellyfinUser( @PrimaryKey(autoGenerate = true) val rowId: Int = 0, @@ -63,36 +50,11 @@ data class JellyfinUser( @ColumnInfo(index = true) val serverId: UUID, val accessToken: String?, - val pin: String? = null, - @ColumnInfo(defaultValue = "false") - val requireLogin: Boolean = false, - val lastUsed: ZonedDateTime? = null, - val uiLanguage: String? = null, - @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), - @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(), ) { - 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()}), " + - "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + - "appPreferences=$appPreferences, config=$config" - - fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config)) + "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken=${accessToken.isNotNullOrBlank()})" } -@Serializable -data class JellyfinUserConfig( - @ColumnInfo(defaultValue = "UNSET") - val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET, -) - -/** - * Represents the relationship between [JellyfinServer] and its [JellyfinUser] - */ data class JellyfinServerUsers( @Embedded val server: JellyfinServer, @Relation( @@ -101,13 +63,3 @@ 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/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index edfc797a..aaafbbf4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -7,22 +7,14 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index -import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID -/** - * Stores the filter, sort, and view options a user changes for a library - * - * This allows for restoring these settings whenever the user navigates to the library - */ @Entity( foreignKeys = [ ForeignKey( @@ -44,17 +36,7 @@ data class LibraryDisplayInfo( val direction: SortOrder, @ColumnInfo(defaultValue = "{}") val filter: GetItemsFilter, - val viewOptions: ViewOptions?, ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) - - constructor( - user: JellyfinUser, - itemId: UUID, - sort: ItemSortBy, - direction: SortOrder, - filter: GetItemsFilter, - viewOptions: ViewOptions?, - ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt deleted file mode 100644 index e312a2b1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/NavDrawerPinnedItem.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey - -enum class NavPinType { - PINNED, - UNPINNED, -} - -/** - * Stores preference information about nav drawer items such as its order and whether to show or put in More - */ -@Entity( - foreignKeys = [ - ForeignKey( - entity = JellyfinUser::class, - parentColumns = arrayOf("rowId"), - childColumns = arrayOf("userId"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - ), - ], - primaryKeys = ["userId", "itemId"], -) -data class NavDrawerPinnedItem( - val userId: Int, - val itemId: String, - val type: NavPinType, - @ColumnInfo(defaultValue = "-1") val order: Int, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index a20a2dcd..3430640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,10 +1,5 @@ package com.github.damontecres.wholphin.data.model -import android.content.Context -import androidx.annotation.StringRes -import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -12,10 +7,6 @@ import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.PersonKind -/** - * Represents a person in some media such as an actor or director - */ -@Stable data class Person( val id: UUID, val name: String?, @@ -26,21 +17,19 @@ data class Person( ) { companion object { fun fromDto( - context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = personRole(context, dto.role, dto.type), + role = dto.role, type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = false, ) fun fromDto( - context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -48,51 +37,10 @@ data class Person( Person( id = dto.id, name = dto.name, - role = personRole(context, dto.role, dto.type), + role = dto.role, type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = favorite, ) } } - -private fun personRole( - context: Context, - role: String?, - type: PersonKind, -): String? = - if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { - role - } else if (role.equals(type.name, ignoreCase = true)) { - type.stringRes?.let { context.getString(it) } - } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { - role - } else { - listOfNotNull( - role?.takeIf { it.isNotNullOrBlank() }, - type.stringRes?.let { context.getString(it) }, - ).takeIf { it.isNotEmpty() }?.joinToString(" - ") - } - -@get:StringRes -private val PersonKind.stringRes: Int? - get() = - when (this) { - PersonKind.UNKNOWN -> R.string.unknown - PersonKind.ACTOR -> R.string.actor - PersonKind.DIRECTOR -> R.string.director - PersonKind.COMPOSER -> R.string.composer - PersonKind.WRITER -> R.string.writer - PersonKind.GUEST_STAR -> R.string.guest_star - PersonKind.PRODUCER -> R.string.producer - PersonKind.CONDUCTOR -> R.string.conductor - PersonKind.LYRICIST -> R.string.lyricist - PersonKind.ARRANGER -> R.string.arranger - PersonKind.ENGINEER -> R.string.engineer - PersonKind.MIXER -> R.string.mixer - PersonKind.REMIXER -> R.string.mixer - PersonKind.CREATOR -> R.string.creator - PersonKind.ARTIST -> R.string.artist - PersonKind.ALBUM_ARTIST -> R.string.artist - else -> null - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt deleted file mode 100644 index 0e7bcd37..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import androidx.room.Embedded -import androidx.room.Entity -import org.jellyfin.sdk.model.api.BaseItemKind -import java.util.UUID - -/** - * Store effects applied to some media such as color or hue adjustments - */ -@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) -data class PlaybackEffect( - val jellyfinUserRowId: Int, - val itemId: UUID, - val type: BaseItemKind, - @Embedded val videoFilter: VideoFilter, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt deleted file mode 100644 index f5507a50..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ /dev/null @@ -1,41 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -package com.github.damontecres.wholphin.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID - -/** - * DEPRECATED - * - * Stores the language choices for a series so they can be applied automatically to other episodes - * without the user needing to explicitly choose the tracks - * - * @see SeriesTrackChoice - */ -@Deprecated("Use SeriesTrackChoice") -@Entity( - foreignKeys = [ - ForeignKey( - entity = JellyfinUser::class, - parentColumns = arrayOf("rowId"), - childColumns = arrayOf("userId"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - ), - ], - primaryKeys = ["userId", "seriesId"], -) -@Serializable -data class PlaybackLanguageChoice( - val userId: Int, - val seriesId: UUID, - val itemId: UUID? = null, - val audioLanguage: String? = null, - val subtitleLanguage: String? = null, - val subtitlesDisabled: Boolean? = null, -) 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 9f10f58c..c4cd3792 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,20 +1,43 @@ 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 -/** - * Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones. - * - * This is not the same thing as a Jellyfin server playlist - */ -data class Playlist( - val items: List<PlaylistItem>, +class Playlist( + items: List<BaseItem>, + startIndex: Int = 0, ) { + 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(): BaseItem = items[--index] + + fun getAndAdvance(): BaseItem = items[++index] + + fun peek(): BaseItem? = items.getOrNull(index + 1) + + fun upcomingItems(): List<BaseItem> = items.subList(index + 1, items.size) + + fun advanceTo(id: UUID): BaseItem? { + 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) }) } } @@ -24,22 +47,3 @@ data class PlaylistInfo( val count: Int, val mediaType: MediaType, ) - -sealed interface PlaylistItem { - val id: UUID - val item: BaseItem - - data class Media( - override val item: BaseItem, - ) : PlaylistItem { - override val id: UUID - get() = item.id - } - - data class Intro( - override val item: BaseItem, - ) : PlaylistItem { - override val id: UUID - get() = item.id - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt deleted file mode 100644 index cd2fece7..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.MediaStreamType - -/** - * A [MediaStream] with its relative track index, its position within a specific stream type - */ -data class RelativeMediaStream( - val relativeIndex: Int, - val stream: MediaStream, -) { - val type: MediaStreamType get() = stream.type - val language: String? get() = stream.language - val isForced: Boolean get() = stream.isForced - val isExternal: Boolean get() = stream.isExternal - val isDefault: Boolean get() = stream.isDefault - val isHearingImpaired: Boolean get() = stream.isHearingImpaired - - companion object { - fun getRelativeIndex( - index: Int, - streams: List<MediaStream>, - ): Int? { - val stream = streams.firstOrNull { it.index == index } ?: return null - return getRelativeIndex(stream, streams) - } - - fun getRelativeIndex( - stream: MediaStream, - streams: List<MediaStream>, - ): Int? = - streams - .filter { it.type == stream.type && it.isExternal == stream.isExternal } - .sortedBy { it.index } - .indexOfFirstOrNull { it.index == stream.index } - - fun from( - index: Int, - streams: List<MediaStream>, - ): RelativeMediaStream? { - val stream = streams.firstOrNull { it.index == index } ?: return null - val relativeIndex = getRelativeIndex(stream, streams) ?: return null - return RelativeMediaStream(relativeIndex, stream) - } - } -} - -/** - * Maps a list of [MediaStream] into [RelativeMediaStream] - */ -fun List<MediaStream>.asRelative(): List<RelativeMediaStream> { - val map = this.groupBy { Pair(it.type, it.isExternal) } - return map.values - .flatMap { typedStreams -> - typedStreams.sortedBy { it.index }.mapIndexed { index, stream -> - RelativeMediaStream(index, stream) - } - }.sortedBy { it.stream.index } -} 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 deleted file mode 100644 index 918384d4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import com.github.damontecres.wholphin.services.SeerrUserConfig - -/** - * Permission levels for a user on a Seerr server - */ -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), - ADMIN(2), - MANAGE_SETTINGS(4), - MANAGE_USERS(8), - MANAGE_REQUESTS(16), - REQUEST(32), - VOTE(64), - AUTO_APPROVE(128), - AUTO_APPROVE_MOVIE(256, AUTO_APPROVE), - AUTO_APPROVE_TV(512, AUTO_APPROVE), - REQUEST_4K(1024), - 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), - AUTO_APPROVE_4K_TV(131072, AUTO_APPROVE_4K), - REQUEST_MOVIE(262144, REQUEST), - REQUEST_TV(524288, REQUEST), - MANAGE_ISSUES(1048576), - VIEW_ISSUES(2097152, MANAGE_ISSUES), - CREATE_ISSUES(4194304, MANAGE_ISSUES), - AUTO_REQUEST(8388608), - 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 -} - -/** - * Check whether the user has the given permissions (or is an admin) - */ -fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { - if (this?.permissions == null) return false - return permission.hasPermission(permissions) || - permission.parents.any { hasPermission(it) } || - SeerrPermission.ADMIN.hasPermission(permissions) -} - -val SeerrUserConfig?.canRequestMovies: Boolean - get() = - hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE) - -val SeerrUserConfig?.canRequestTv: Boolean - get() = - hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt deleted file mode 100644 index 1ff7212b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -sealed interface SeerrPluginLogin { - @SerialName("None") - @Serializable - data object None : SeerrPluginLogin - - @SerialName("ApiKey") - @Serializable - data class ApiKey( - val apiKey: String, - ) : SeerrPluginLogin - - @SerialName("Local") - @Serializable - data class Local( - val local: SeerrPluginLocalLogin, - ) : SeerrPluginLogin -} - -@Serializable -data class SeerrPluginLocalLogin( - val username: String, - val password: String, -) - -@Serializable -data class SeerrPluginSettings( - val version: Int = 1, - val serverUrl: String? = null, - val login: SeerrPluginLogin? = null, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt deleted file mode 100644 index 311379e4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt +++ /dev/null @@ -1,84 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -package com.github.damontecres.wholphin.data.model - -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey -import androidx.room.Relation -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.serializer.UUIDSerializer - -/** - * Represents a Seerr server instance - */ -@Entity( - tableName = "seerr_servers", - indices = [Index("url", unique = true)], -) -@Serializable -data class SeerrServer( - @PrimaryKey(autoGenerate = true) - val id: Int = 0, - val url: String, - val name: String? = null, - val version: String? = null, -) - -/** - * Represents a user on a [SeerrServer] - */ -@Entity( - tableName = "seerr_users", - foreignKeys = [ - ForeignKey( - entity = SeerrServer::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("serverId"), - onDelete = ForeignKey.CASCADE, - ), - ForeignKey( - entity = JellyfinUser::class, - parentColumns = arrayOf("rowId"), - childColumns = arrayOf("jellyfinUserRowId"), - onDelete = ForeignKey.CASCADE, - ), - ], - primaryKeys = ["jellyfinUserRowId", "serverId"], -) -data class SeerrUser( - val jellyfinUserRowId: Int, - val serverId: Int, - val authMethod: SeerrAuthMethod, - val username: String?, - val password: String?, - val credential: String?, -) { - override fun toString(): String = - "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" -} - -/** - * The method used to authenticate a user to the server - */ -enum class SeerrAuthMethod { - LOCAL, - JELLYFIN, - API_KEY, -} - -/** - * Represents the relationship between a [SeerrServer] and its [SeerrUser]s - */ -data class SeerrServerUsers( - @Embedded val server: SeerrServer, - @Relation( - parentColumn = "id", - entityColumn = "serverId", - ) - val users: List<SeerrUser>, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt deleted file mode 100644 index 5dd313d6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt +++ /dev/null @@ -1,199 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -package com.github.damontecres.wholphin.data.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import com.github.damontecres.wholphin.services.isSigns -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import java.util.UUID - -/** - * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks - * - * Additional information about the chosen track is stored to compare against tracks in other media - */ -@Entity( - foreignKeys = [ - ForeignKey( - entity = JellyfinUser::class, - parentColumns = arrayOf("rowId"), - childColumns = arrayOf("userId"), - onDelete = ForeignKey.CASCADE, - onUpdate = ForeignKey.CASCADE, - ), - ], - primaryKeys = ["userId", "parentId", "type"], -) -@Serializable -data class SeriesTrackChoice( - val userId: Int, - /** - * Season or Series - */ - val parentId: UUID, - /** - * Type of track, ie audio or subtitle - */ - val type: SeriesTrackChoiceType, - /** - * Origin of the [parentId] - */ - val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN, - val itemId: UUID? = null, - val language: String? = null, - val activation: ActivationFlag = ActivationFlag.ACTIVATED, - /** - * Flags for the track such as external or forced - * - * @see [TrackFlag] - */ - val trackFlags: Int = 0, - val codec: String? = null, - /** - * The track index based on its position within all streams of the same type - * - * @see [RelativeMediaStream] - */ - val relativeTrackIndex: Int? = null, - val title: String? = null, - val audioChannels: Int? = null, -) { - constructor( - userId: Int, - itemId: UUID, - parentId: UUID, - type: SeriesTrackChoiceType, - parentType: TrackChoiceParentType, - stream: RelativeMediaStream?, - activation: ActivationFlag, - ) : this( - userId = userId, - parentId = parentId, - parentType = parentType, - type = type, - itemId = itemId, - language = stream?.language, - activation = activation, - trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0, - codec = stream?.stream?.codec, - relativeTrackIndex = stream?.relativeIndex, - title = stream?.stream?.title, - audioChannels = stream?.stream?.channels, - ) - - companion object { - /** - * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s - */ - fun from(plc: PlaybackLanguageChoice): List<SeriesTrackChoice> = - buildList { - if (plc.subtitleLanguage != null) { - add( - SeriesTrackChoice( - userId = plc.userId, - parentId = plc.seriesId, - parentType = TrackChoiceParentType.DEPRECATED, - type = SeriesTrackChoiceType.SUBTITLE, - itemId = plc.itemId, - language = plc.subtitleLanguage, - activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ), - ) - } - if (plc.audioLanguage != null) { - add( - SeriesTrackChoice( - userId = plc.userId, - parentId = plc.seriesId, - parentType = TrackChoiceParentType.DEPRECATED, - type = SeriesTrackChoiceType.AUDIO, - itemId = plc.itemId, - language = plc.audioLanguage, - activation = ActivationFlag.ACTIVATED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ), - ) - } - } - } -} - -enum class SeriesTrackChoiceType( - val mediaStreamType: MediaStreamType, -) { - AUDIO(MediaStreamType.AUDIO), - SUBTITLE(MediaStreamType.SUBTITLE), -} - -/** - * Flags for a track such as forced or defect - */ -enum class TrackFlag( - val flag: Int, - val hasFlag: (MediaStream) -> Boolean, -) { - DEFAULT(flag = 1, hasFlag = { it.isDefault }), - FORCED(flag = 2, hasFlag = { it.isForced }), - SDH(flag = 4, hasFlag = { it.isHearingImpaired }), - EXTERNAL(flag = 8, hasFlag = { it.isExternal }), - SIGNS(flag = 16, hasFlag = { isSigns(it) }), - ; - - fun within(flag: Int) = flag and this.flag == this.flag - - companion object { - fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags) - - fun Iterable<TrackFlag>.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag } - - fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag() - } -} - -/** - * How was the track activated - */ -enum class ActivationFlag { - ACTIVATED, - DISABLED, - ONLY_FORCED, -} - -/** - * Where the [SeriesTrackChoice.parentId] comes from - */ -enum class TrackChoiceParentType { - /** - * Parent ID is from the series - */ - SERIES, - - /** - * Parent ID is from the season - */ - SEASON, - - /** - * Parent ID is from the deprecated [PlaybackLanguageChoice] - */ - DEPRECATED, - - /** - * Parent ID origin is unknown - */ - UNKNOWN, -} 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/ServerPreferences.kt similarity index 78% rename from app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt rename to app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt index 2fc6f702..f3f52585 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/RememberedTab.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt @@ -2,7 +2,11 @@ package com.github.damontecres.wholphin.data.model import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index + +enum class NavPinType { + PINNED, + UNPINNED, +} @Entity( foreignKeys = [ @@ -14,11 +18,10 @@ import androidx.room.Index onUpdate = ForeignKey.CASCADE, ), ], - indices = [Index("userId", "itemId", unique = true)], primaryKeys = ["userId", "itemId"], ) -data class RememberedTab( +data class NavDrawerPinnedItem( val userId: Int, val itemId: String, - val index: Int, + val type: NavPinType, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt deleted file mode 100644 index 0eaaa848..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerUserConfig.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import org.jellyfin.sdk.model.api.UserConfiguration -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.UserPolicy -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import java.util.UUID - -/** - * Server side configuration for a user, similar to [UserDto] but without the timestamps allowing for more reliable equals - */ -data class ServerUserConfig( - val id: UUID, - val name: String? = null, - val serverId: UUID? = null, - val configuration: UserConfiguration? = null, - val policy: UserPolicy? = null, -) { - constructor(userDto: UserDto) : this( - id = userDto.id, - name = userDto.name, - serverId = userDto.serverId?.toUUIDOrNull(), - configuration = userDto.configuration, - policy = userDto.policy, - ) - - val tvAccess: Boolean get() = policy?.enableLiveTvAccess == true -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt deleted file mode 100644 index 581a4aa2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import androidx.compose.runtime.Immutable -import java.util.Locale - -/** - * Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage. - */ -enum class RtMark { - FRESH, - ROTTEN, - UPRIGHT, - SPILLED, - - /** Seerr sent a score without a recognised label */ - UNKNOWN, - ; - - companion object { - /** - * Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null - */ - fun fromLabel(label: String?): RtMark? = - when (label) { - "Certified Fresh", "Fresh" -> FRESH - "Rotten" -> ROTTEN - "Upright" -> UPRIGHT - "Spilled" -> SPILLED - else -> null - } - } -} - -@Immutable -data class RtScore( - /** 0-100 */ - val percent: Int, - val mark: RtMark, -) - -/** - * The badge drawn in front of a rating's number. One per PWA icon. - */ -enum class RatingBadge { - RT_FRESH, - RT_ROTTEN, - RT_UPRIGHT, - RT_SPILLED, - - /** Rotten Tomatoes without a recognised label, drawn muted */ - RT_UNKNOWN, - IMDB, - TMDB, -} - -/** - * One displayable rating: a badge and its number - */ -@Immutable -sealed interface RatingEntry { - val text: String - val badge: RatingBadge - - /** - * An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series - */ - val isEpisode: Boolean get() = false - - data class RottenTomatoes( - val score: RtScore, - /** Critics (Tomatometer) or audience (Popcornmeter) */ - val critics: Boolean, - ) : RatingEntry { - override val text: String get() = "${score.percent}%" - override val badge: RatingBadge - get() = - when (score.mark) { - RtMark.FRESH -> RatingBadge.RT_FRESH - RtMark.ROTTEN -> RatingBadge.RT_ROTTEN - RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT - RtMark.SPILLED -> RatingBadge.RT_SPILLED - RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN - } - } - - data class Imdb( - val score: Double, - ) : RatingEntry { - override val text: String get() = formatScore(score) - override val badge: RatingBadge get() = RatingBadge.IMDB - } - - data class Tmdb( - val score: Double, - ) : RatingEntry { - override val text: String get() = formatScore(score) - override val badge: RatingBadge get() = RatingBadge.TMDB - } - - data class EpisodeTmdb( - val score: Double, - ) : RatingEntry { - override val text: String get() = formatScore(score) - override val badge: RatingBadge get() = RatingBadge.TMDB - override val isEpisode: Boolean get() = true - } -} - -private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) - -/** - * Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience, - * IMDb (movies only), and TMDB. - * - * Only scores that were actually observed are present. Use [of] to apply the PWA's rules. - */ -@Immutable -data class SourceRatings( - val rtCritics: RtScore? = null, - val rtAudience: RtScore? = null, - val imdb: Double? = null, - val tmdb: Double? = null, - /** The TMDB user score of the episode itself, for episodes */ - val episodeTmdb: Double? = null, -) { - val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null - - /** - * Rotten Tomatoes entries in PWA order: critics then audience - */ - val criticEntries: List<RatingEntry.RottenTomatoes> - get() = - listOfNotNull( - rtCritics?.let { RatingEntry.RottenTomatoes(it, true) }, - rtAudience?.let { RatingEntry.RottenTomatoes(it, false) }, - ) - - /** - * IMDb or TMDB - */ - val communityEntries: List<RatingEntry> - get() = - listOfNotNull( - imdb?.let { RatingEntry.Imdb(it) }, - tmdb?.let { RatingEntry.Tmdb(it) }, - episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) }, - ) - - /** - * These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the - * series' own TMDB score is dropped so there is a single TMDB number, which is the episode's. - * - * @param episodeScore from [episodeScore], if null these ratings are unchanged - */ - fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore) - - /** - * The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB). - * - * Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting. - */ - fun visibleEntries( - community: Boolean, - critic: Boolean, - ): List<RatingEntry> = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList()) - - companion object { - const val MAX_PERCENT = 100.0 - const val MAX_SCORE = 10.0 - - /** - * An episode's TMDB user score by the PWA's rules for a user score: only if observed, within 0-10, and with at least one vote - * (no votes or no vote count is unrated, never 0) - */ - fun episodeScore( - score: Number?, - votes: Long?, - ): Double? = if (votes == null) null else userScore(score, votes) - - /** - * Builds ratings using the PWA's rules: - * - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped - * - A zero-vote IMDb/TMDB score is unrated, never 0 - * - RT icon comes from the label Seerr reports - * - TMDB is kept only when no other source has a score - * - * @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten - * @param rtAudienceLabel Seerr's audience status: Upright or Spilled - */ - fun of( - rtCriticsScore: Number? = null, - rtCriticsLabel: String? = null, - rtAudienceScore: Number? = null, - rtAudienceLabel: String? = null, - imdbScore: Number? = null, - imdbVotes: Long? = null, - tmdbScore: Number? = null, - tmdbVotes: Long? = null, - ): SourceRatings { - val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN) - val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT) - val imdb = userScore(imdbScore, imdbVotes) - val tmdb = userScore(tmdbScore, tmdbVotes) - return if (critics != null || audience != null || imdb != null) { - SourceRatings(critics, audience, imdb, null) - } else { - SourceRatings(tmdb = tmdb) - } - } - - private fun rtScore( - score: Number?, - label: String?, - defaultMark: RtMark, - ): RtScore? { - val value = score?.toDouble() ?: return null - if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null - return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark) - } - - private fun userScore( - score: Number?, - votes: Long?, - ): Double? { - val value = score?.toDouble() ?: return null - if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null - if (votes != null && votes <= 0L) return null - return value - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt index 01e473b2..bf82bbf3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt @@ -1,15 +1,9 @@ package com.github.damontecres.wholphin.data.model -/** - * Represents a trailer for media - */ sealed interface Trailer { val name: String } -/** - * A [Trailer] stored on the Jellyfin server - */ data class LocalTrailer( val baseItem: BaseItem, ) : Trailer { @@ -17,11 +11,7 @@ data class LocalTrailer( get() = baseItem.name ?: "" } -/** - * A [Trailer] available via a remote URL, such as YouTube - */ data class RemoteTrailer( override val name: String, val url: String, - val subtitle: String?, ) : Trailer diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt deleted file mode 100644 index 93543e23..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import android.graphics.ColorMatrix -import androidx.annotation.IntRange -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.effect.Brightness -import androidx.media3.effect.Contrast -import androidx.media3.effect.GaussianBlur -import androidx.media3.effect.GlEffect -import androidx.media3.effect.HslAdjustment -import androidx.media3.effect.RgbAdjustment -import androidx.media3.effect.ScaleAndRotateTransformation -import androidx.room.Ignore - -/** - * Modifications to an image or video playback - */ -data class VideoFilter( - val rotation: Int = 0, - @param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT, - @param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT, - @param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT, - @param:IntRange(0, 360) val hue: Int = HUE_DEFAULT, - @param:IntRange(0, 200) val red: Int = COLOR_DEFAULT, - @param:IntRange(0, 200) val green: Int = COLOR_DEFAULT, - @param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT, - @param:IntRange(0, 250) val blur: Int = 0, -) { - @Ignore - val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix() - - companion object { - const val COLOR_DEFAULT = 100 - const val HUE_DEFAULT = 0 - } - - fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0 - - fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT - - fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT - - fun hasContrast(): Boolean = contrast != COLOR_DEFAULT - - fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT - - fun hasBlur(): Boolean = blur > 0 - - fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT - - @OptIn(UnstableApi::class) - private fun rgbAdjustment(): RgbAdjustment = - RgbAdjustment - .Builder() - .setRedScale(red / COLOR_DEFAULT.toFloat()) - .setGreenScale(green / COLOR_DEFAULT.toFloat()) - .setBlueScale(blue / COLOR_DEFAULT.toFloat()) - .build() - - /** - * Create the list of effects to apply - */ - @OptIn(UnstableApi::class) - fun createEffectList(): List<GlEffect> = - buildList { - if (isRotated()) { - add( - ScaleAndRotateTransformation - .Builder() - .setRotationDegrees(rotation.toFloat()) - .build(), - ) - } - if (hasRgb()) { - add(rgbAdjustment()) - } - if (hasBrightness()) { - add(Brightness((brightness - 100) / 100f)) - } - if (hasContrast()) { - add(Contrast((contrast - 100) / 100f)) - } - if (hasHsl()) { - add( - HslAdjustment - .Builder() - .adjustHue(hue.toFloat()) - .adjustSaturation((saturation - 100).toFloat()) - .build(), - ) - } - if (hasBlur()) { - add(GaussianBlur(blur / 10f)) - } - } - - private fun saturationMatrix(): FloatArray { - val rF = 0.2999f - val gF = 0.587f - val bF = 0.114f - val s = saturation / 100.0f - - val ms = 1.0f - s - val rT = rF * ms - val gT = gF * ms - val bT = bF * ms - - val m = - FloatArray(20) { - when (it) { - 0 -> (rT + s) - 1 -> gT - 2 -> bT - 5 -> rT - 6 -> (gT + s) - 7 -> bT - 10 -> rT - 11 -> gT - 12 -> (bT + s) - 18 -> 1f - else -> 0f - } - } - return m - } - - @OptIn(UnstableApi::class) - fun createColorMatrix(): ColorMatrix { - val matrix = ColorMatrix() - val tempMatrix = ColorMatrix() - - if (saturation != COLOR_DEFAULT) { - matrix.set(saturationMatrix()) - } - if (hasRgb()) { - val colorMatrix = rgbAdjustment().getMatrix(0L, false) - val m = FloatArray(20) - m[0] = colorMatrix[0] - m[1] = colorMatrix[1] - m[2] = colorMatrix[2] - m[3] = colorMatrix[3] - m[4] = 0f - m[5] = colorMatrix[4] - m[6] = colorMatrix[5] - m[7] = colorMatrix[6] - m[8] = colorMatrix[7] - m[9] = 0f - m[10] = colorMatrix[8] - m[11] = colorMatrix[9] - m[12] = colorMatrix[10] - m[13] = colorMatrix[11] - m[14] = 0f - m[15] = colorMatrix[12] - m[16] = colorMatrix[13] - m[17] = colorMatrix[14] - m[18] = colorMatrix[15] - m[19] = 0f - tempMatrix.set(m) - matrix.postConcat(tempMatrix) - } - if (hasContrast()) { - val scale = contrast / 100.0f - tempMatrix.setScale(scale, scale, scale, 1f) - matrix.postConcat(tempMatrix) - } - if (hasBrightness()) { - val b = brightness / 100.0f - val m = FloatArray(20) - m[0] = b - m[6] = b - m[12] = b - m[18] = 1f - tempMatrix.set(m) - matrix.postConcat(tempMatrix) - } - // TODO hue - // TODO blur - return matrix - } - - @OptIn(UnstableApi::class) - fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix { - val matrix = - androidx.compose.ui.graphics - .ColorMatrix() - - if (saturation != COLOR_DEFAULT) { - matrix.setToSaturation(saturation / 100f) - } - if (hasRgb()) { - matrix.setToScale( - redScale = red / 100f, - greenScale = green / 100f, - blueScale = blue / 100f, - alphaScale = 1f, - ) - } - if (hasContrast()) { - val scale = contrast / 100.0f - val tempMatrix = - androidx.compose.ui.graphics - .ColorMatrix() - tempMatrix.setToScale(scale, scale, scale, 1f) - matrix.timesAssign(tempMatrix) - } - if (hasBrightness()) { - val b = brightness / 100.0f - val m = FloatArray(20) - m[0] = b - m[6] = b - m[12] = b - m[18] = 1f - val tempMatrix = - androidx.compose.ui.graphics - .ColorMatrix(m) - matrix.timesAssign(tempMatrix) - } - // TODO hue - // TODO blur - return matrix - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 5d43b394..64cdd505 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 @@ -5,17 +5,14 @@ import androidx.annotation.ArrayRes import androidx.annotation.StringRes import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.ui.formatMaxBitrate 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.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation -import com.github.damontecres.wholphin.util.DebugLogTree import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes @@ -26,7 +23,7 @@ import kotlin.time.Duration.Companion.seconds * * @param T The type of the preference value. */ -sealed interface AppPreference<Pref, T> { +sealed interface AppPreference<T> { /** * String resource ID for the title of the preference */ @@ -42,29 +39,26 @@ sealed interface AppPreference<Pref, T> { * A function that gets the value from the [AppPreferences] object for UI purposes. This means * that it should return the value that is displayed in the UI, which isn't necessarily the raw value */ - val getter: (prefs: Pref) -> T + val getter: (prefs: AppPreferences) -> T /** * A function that sets the value in the [AppPreferences] object from the UI. It should convert the value if needed */ - val setter: (prefs: Pref, value: T) -> Pref + val setter: (prefs: AppPreferences, value: T) -> AppPreferences fun summary( context: Context, value: T?, ): String? = null - fun validate( - prefs: Pref, - value: T, - ): PreferenceValidation = PreferenceValidation.Valid + fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid companion object { val SkipForward = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.skip_forward_preference, defaultValue = 30, - min = 5, + min = 10, max = 5.minutes.inWholeSeconds, interval = 5, getter = { @@ -90,7 +84,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipBack = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.skip_back_preference, defaultValue = 10, min = 5, @@ -118,8 +112,32 @@ sealed interface AppPreference<Pref, T> { }, ) +// val GridJumpButtons = +// AppSwitchPreference( +// title = R.string.show_grid_jump_buttons, +// defaultValue = true, +// getter = { it.interfacePreferences.showGridJumpButtons }, +// setter = { prefs, value -> +// prefs.updateInterfacePreferences { showGridJumpButtons = value } +// }, +// summaryOn = R.string.enabled, +// summaryOff = R.string.disabled, +// ) + +// val ShowGridFooter = +// AppSwitchPreference( +// title = R.string.grid_position_footer, +// defaultValue = true, +// getter = { it.interfacePreferences.showPositionFooter }, +// setter = { prefs, value -> +// prefs.updateInterfacePreferences { showPositionFooter = value } +// }, +// summaryOn = R.string.show, +// summaryOff = R.string.hide, +// ) + val ControllerTimeout = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.hide_controller_timeout, defaultValue = 5000, min = 500, @@ -140,7 +158,7 @@ sealed interface AppPreference<Pref, T> { ) val SeekBarSteps = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.seek_bar_steps, defaultValue = 16, min = 4, @@ -154,7 +172,7 @@ sealed interface AppPreference<Pref, T> { ) val HomePageItems = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.max_homepage_items, defaultValue = 25, min = 5, @@ -167,47 +185,20 @@ sealed interface AppPreference<Pref, T> { summarizer = { value -> value?.toString() }, ) - val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365) - val MaxDaysNextUp = - AppSliderPreference<AppPreferences>( - title = R.string.max_days_next_up, - defaultValue = -1, - min = 0, - // Max is "no limit" stored as -1 - max = MaxDaysNextUpOptions.lastIndex + 1L, - interval = 1, - getter = { - MaxDaysNextUpOptions - .indexOf(it.homePagePreferences.maxDaysNextUp) - .takeIf { it in MaxDaysNextUpOptions.indices } - ?.toLong() - ?: MaxDaysNextUpOptions.size.toLong() - }, - setter = { prefs, index -> - prefs.updateHomePagePreferences { - maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1 - } - }, - summarizer = { value -> - if (value != null) { - val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1 - if (v == -1) { - WholphinApplication.instance.getString(R.string.no_limit) - } else { - WholphinApplication.instance.resources.getQuantityString( - R.plurals.days, - v, - v.toString(), - ) - } - } else { - null - } + val CombineContinueNext = + AppSwitchPreference( + title = R.string.combine_continue_next, + defaultValue = false, + getter = { it.homePagePreferences.combineContinueNext }, + setter = { prefs, value -> + prefs.updateHomePagePreferences { combineContinueNext = value } }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, ) val RewatchNextUp = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.rewatch_next_up, defaultValue = false, getter = { it.homePagePreferences.enableRewatchingNextUp }, @@ -218,21 +209,8 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) - val HomeClickToPlay = - AppChoicePreference<AppPreferences, Boolean>( - title = R.string.continue_watching_click_behavior, - defaultValue = false, - getter = { it.homePagePreferences.clickToPlay }, - setter = { prefs, value -> - prefs.updateHomePagePreferences { clickToPlay = value } - }, - displayValues = R.array.home_click_to_play_options, - indexToValue = { it != 0 }, - valueToIndex = { if (it) 1 else 0 }, - ) - val PlayThemeMusic = - AppChoicePreference<AppPreferences, ThemeSongVolume>( + AppChoicePreference<ThemeSongVolume>( title = R.string.play_theme_music, defaultValue = ThemeSongVolume.MEDIUM, getter = { it.interfacePreferences.playThemeSongs }, @@ -245,7 +223,7 @@ sealed interface AppPreference<Pref, T> { ) val PlaybackDebugInfo = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.playback_debug_info, defaultValue = false, getter = { it.playbackPreferences.showDebugInfo }, @@ -257,7 +235,7 @@ sealed interface AppPreference<Pref, T> { ) val AutoPlayNextUp = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.auto_play_next, defaultValue = true, getter = { it.playbackPreferences.autoPlayNext }, @@ -269,7 +247,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipBackOnResume = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.skip_back_on_resume_preference, defaultValue = 0, min = 0, @@ -291,7 +269,7 @@ sealed interface AppPreference<Pref, T> { ) val AutoPlayNextDelay = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.auto_play_next_delay, defaultValue = 15, min = 0, @@ -317,7 +295,7 @@ sealed interface AppPreference<Pref, T> { ) val PassOutProtection = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.pass_out_protection, defaultValue = 2, min = 0, @@ -344,9 +322,9 @@ sealed interface AppPreference<Pref, T> { }, ) - const val MEGA_BIT = 1024 * 1024L + private const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - val bitrateValues = + private val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -362,7 +340,7 @@ sealed interface AppPreference<Pref, T> { *(120..200 step 20).map { it * MEGA_BIT }.toTypedArray(), ) val MaxBitrate = - AppSliderPreference<AppPreferences>( + AppSliderPreference( title = R.string.max_bitrate, defaultValue = bitrateValues.indexOf(DEFAULT_BITRATE).toLong(), min = 0, @@ -379,70 +357,52 @@ sealed interface AppPreference<Pref, T> { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - formatMaxBitrate(v) + if (v < MEGA_BIT) { + "${v / 1024} kbps" + } else { + "${v / MEGA_BIT} Mbps" + } } else { null } }, ) - val MaxResolutionPref = - AppChoicePreference<AppPreferences, MaxResolution>( - title = R.string.max_resolution, - defaultValue = MaxResolution.NO_LIMIT, - getter = { MaxResolution.fromValue(it.playbackPreferences.overrides.maxResolution) }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { maxResolution = value.value } - }, - displayValues = R.array.max_resolution_options, - indexToValue = { MaxResolution.entries.getOrNull(it) ?: MaxResolution.NO_LIMIT }, - valueToIndex = { it.ordinal }, - ) - val Ac3Supported = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.ac3_supported, defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - if (!value) prefs.updateExperimentalPreferences { preferAc3Surround = false } - prefs.updatePlaybackOverrides { - ac3Supported = value - } + prefs.updatePlaybackOverrides { ac3Supported = value } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) - val DownMixStereo = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - if (value) prefs.updateExperimentalPreferences { preferAc3Surround = false } - prefs.updatePlaybackOverrides { - downmixStereo = value - } + prefs.updatePlaybackOverrides { downmixStereo = value } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) - val AssSubtitleMode = - AppChoicePreference<AppPreferences, AssPlaybackMode>( - title = R.string.ass_subtitle_playback, - defaultValue = AssPlaybackMode.ASS_LIBASS, - getter = { it.playbackPreferences.overrides.assPlaybackMode }, + val DirectPlayAss = + AppSwitchPreference( + title = R.string.direct_play_ass, + defaultValue = true, + getter = { it.playbackPreferences.overrides.directPlayAss }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { assPlaybackMode = value } + prefs.updatePlaybackOverrides { directPlayAss = value } }, - displayValues = R.array.ass_subtitle_modes, - subtitles = R.array.ass_subtitle_modes_summary, - indexToValue = { AssPlaybackMode.forNumber(it) }, - valueToIndex = { if (it != AssPlaybackMode.UNRECOGNIZED) it.number else AssPlaybackMode.ASS_LIBASS.number }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, ) val DirectPlayPgs = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.direct_play_pgs, defaultValue = true, getter = { it.playbackPreferences.overrides.directPlayPgs }, @@ -453,97 +413,8 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) - val Hdr10Override = - AppChoicePreference<AppPreferences, HdrOverrideMode>( - title = R.string.hdr10_override, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.hdr10Override }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { hdr10Override = value } - }, - displayValues = R.array.hdr_override_modes, - indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, - valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, - ) - - val Hdr10PlusOverride = - AppChoicePreference<AppPreferences, HdrOverrideMode>( - title = R.string.hdr10_plus_override, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { hdr10PlusOverride = value } - }, - displayValues = R.array.hdr_override_modes, - indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, - valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, - ) - - val DoviProfile5Override = - AppChoicePreference<AppPreferences, HdrOverrideMode>( - title = R.string.dovi_profile_5, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.doviProfile5Override }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { doviProfile5Override = value } - }, - displayValues = R.array.hdr_override_modes, - indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, - valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, - ) - - val DoviProfile7Override = - AppChoicePreference<AppPreferences, HdrOverrideMode>( - title = R.string.dovi_profile_7, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.doviProfile7Override }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { doviProfile7Override = value } - }, - displayValues = R.array.hdr_override_modes, - indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, - valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, - ) - - val DoviProfile8Override = - AppChoicePreference<AppPreferences, HdrOverrideMode>( - title = R.string.dovi_profile_8, - defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, - getter = { it.playbackPreferences.overrides.doviProfile8Override }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { doviProfile8Override = value } - }, - displayValues = R.array.hdr_override_modes, - indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, - valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, - ) - - val DecodeAv1 = - AppSwitchPreference<AppPreferences>( - title = R.string.software_decoding_av1, - defaultValue = true, - getter = { it.playbackPreferences.overrides.decodeAv1 }, - setter = { prefs, value -> - prefs.updatePlaybackOverrides { decodeAv1 = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val CinemaMode = - AppSwitchPreference<AppPreferences>( - title = R.string.cinema_mode, - defaultValue = true, - getter = { it.playbackPreferences.cinemaMode }, - setter = { prefs, value -> - prefs.updatePlaybackPreferences { cinemaMode = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val RememberSelectedTab = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.remember_selected_tab, defaultValue = false, getter = { it.interfacePreferences.rememberSelectedTab }, @@ -555,7 +426,7 @@ sealed interface AppPreference<Pref, T> { ) val ThemeColors = - AppChoicePreference<AppPreferences, AppThemeColors>( + AppChoicePreference<AppThemeColors>( title = R.string.app_theme, defaultValue = AppThemeColors.PURPLE, getter = { it.interfacePreferences.appThemeColors }, @@ -567,53 +438,22 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 }, ) - val ShowLogos = - AppSwitchPreference<AppPreferences>( - title = R.string.prefer_logos, - defaultValue = true, - getter = { it.interfacePreferences.showLogos }, - setter = { prefs, value -> - prefs.updateInterfacePreferences { showLogos = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val DisplayTogglesPref = - AppMultiChoicePreference<AppPreferences, DisplayToggle>( - title = R.string.display_toggles_title, - summary = R.string.display_toggles_summary, - defaultValue = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED }, - allValues = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED }, - displayValues = R.array.display_toggle_types, - displayValuesSubtitles = R.array.display_toggle_types_subtitles, - getter = { - it.interfacePreferences.displayTogglesList - }, - setter = { prefs, value -> - prefs.updateInterfacePreferences { - clearDisplayToggles() - addAllDisplayToggles(value) - } - }, - ) - val InstalledVersion = - AppClickablePreference<AppPreferences>( + AppClickablePreference( title = R.string.installed_version, getter = { }, setter = { prefs, _ -> prefs }, ) val Update = - AppClickablePreference<AppPreferences>( + AppClickablePreference( title = R.string.check_for_updates, getter = { }, setter = { prefs, _ -> prefs }, ) val AutoCheckForUpdates = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.auto_check_for_updates, defaultValue = true, getter = { it.autoCheckForUpdates }, @@ -625,9 +465,9 @@ sealed interface AppPreference<Pref, T> { ) val UpdateUrl = - AppStringPreference<AppPreferences>( + AppStringPreference( title = R.string.update_url, - defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", + defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", getter = { it.updateUrl }, setter = { prefs, value -> prefs.update { updateUrl = value } @@ -636,64 +476,19 @@ sealed interface AppPreference<Pref, T> { ) val OssLicenseInfo = - AppDestinationPreference<AppPreferences>( + AppDestinationPreference( title = R.string.license_info, destination = Destination.License, ) val AdvancedSettings = - AppDestinationPreference<AppPreferences>( + AppDestinationPreference( title = R.string.advanced_settings, destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) - val WolfskinScreens = - AppSwitchPreference<AppPreferences>( - title = R.string.wolfskin_screens, - defaultValue = true, - getter = { org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled }, - setter = { prefs, value -> - org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled = value - prefs - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val ConductarrUrl = - AppStringPreference<AppPreferences>( - title = R.string.wolfskin_conductarr_url, - defaultValue = com.github.damontecres.wholphin.BuildConfig.CONDUCTARR_BASE_URL, - getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.baseUrl }, - setter = { prefs, value -> - org.justinvisser.wolfskin.tv.ConductarrPreference.updateBaseUrl(value) - prefs - }, - summary = R.string.wolfskin_conductarr_url_summary, - ) - - val WolfskinFixtures = - AppSwitchPreference<AppPreferences>( - title = R.string.wolfskin_fixtures, - defaultValue = false, - getter = { org.justinvisser.wolfskin.tv.ConductarrPreference.fixtures }, - setter = { prefs, value -> - org.justinvisser.wolfskin.tv.ConductarrPreference.updateFixtures(value) - prefs - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - 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>( + AppChoicePreference<SkipSegmentBehavior>( title = R.string.skip_intro_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipIntros }, @@ -706,7 +501,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipOutros = - AppChoicePreference<AppPreferences, SkipSegmentBehavior>( + AppChoicePreference<SkipSegmentBehavior>( title = R.string.skip_outro_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipOutros }, @@ -719,7 +514,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipCommercials = - AppChoicePreference<AppPreferences, SkipSegmentBehavior>( + AppChoicePreference<SkipSegmentBehavior>( title = R.string.skip_commercials_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipCommercials }, @@ -732,7 +527,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipPreviews = - AppChoicePreference<AppPreferences, SkipSegmentBehavior>( + AppChoicePreference<SkipSegmentBehavior>( title = R.string.skip_previews_behavior, defaultValue = SkipSegmentBehavior.IGNORE, getter = { it.playbackPreferences.skipPreviews }, @@ -745,7 +540,7 @@ sealed interface AppPreference<Pref, T> { ) val SkipRecaps = - AppChoicePreference<AppPreferences, SkipSegmentBehavior>( + AppChoicePreference<SkipSegmentBehavior>( title = R.string.skip_recap_behavior, defaultValue = SkipSegmentBehavior.IGNORE, getter = { it.playbackPreferences.skipRecaps }, @@ -757,29 +552,8 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { if (it != SkipSegmentBehavior.UNRECOGNIZED) it.number else 0 }, ) - val SkipSegments = - AppDestinationPreference<AppPreferences>( - title = R.string.skip_behavior, - summary = R.string.skip_behavior_summary, - 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>( + AppChoicePreference<PrefContentScale>( title = R.string.global_content_scale, defaultValue = PrefContentScale.FIT, getter = { it.playbackPreferences.globalContentScale }, @@ -792,7 +566,7 @@ sealed interface AppPreference<Pref, T> { ) val FfmpegPreference = - AppChoicePreference<AppPreferences, MediaExtensionStatus>( + AppChoicePreference<MediaExtensionStatus>( title = R.string.ffmpeg_extension_pref, defaultValue = MediaExtensionStatus.MES_FALLBACK, getter = { it.playbackPreferences.overrides.mediaExtensionsEnabled }, @@ -805,36 +579,22 @@ sealed interface AppPreference<Pref, T> { ) val ClearImageCache = - AppClickablePreference<AppPreferences>( + AppClickablePreference( title = R.string.clear_image_cache, getter = { }, setter = { prefs, _ -> prefs }, ) val UserPinnedNavDrawerItems = - AppClickablePreference<AppPreferences>( + AppClickablePreference( title = R.string.nav_drawer_pins, summary = R.string.nav_drawer_pins_summary, getter = { }, setter = { prefs, _ -> prefs }, ) - val CustomizeHome = - AppDestinationPreference<AppPreferences>( - title = R.string.customize_home, - destination = Destination.HomeSettings, - summary = R.string.customize_home_summary, - ) - - val UserInterfaceLanguage = - AppClickablePreference<AppPreferences>( - title = R.string.user_interface_language, - getter = { }, - setter = { prefs, _ -> prefs }, - ) - val SendCrashReports = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.send_crash_reports, defaultValue = true, getter = { @@ -854,7 +614,7 @@ sealed interface AppPreference<Pref, T> { ) val SendAppLogs = - AppClickablePreference<AppPreferences>( + AppClickablePreference( title = R.string.send_app_logs, summary = R.string.send_app_logs_summary, getter = { }, @@ -862,7 +622,7 @@ sealed interface AppPreference<Pref, T> { ) val NavDrawerSwitchOnFocus = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.nav_drawer_switch_on_focus, defaultValue = true, getter = { it.interfacePreferences.navDrawerSwitchOnFocus }, @@ -874,7 +634,7 @@ sealed interface AppPreference<Pref, T> { ) val ShowNextUpTiming = - AppChoicePreference<AppPreferences, ShowNextUpWhen>( + AppChoicePreference<ShowNextUpWhen>( title = R.string.show_next_up_when, defaultValue = ShowNextUpWhen.END_OF_PLAYBACK, getter = { it.playbackPreferences.showNextUpWhen }, @@ -887,7 +647,7 @@ sealed interface AppPreference<Pref, T> { ) val ShowClock = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.show_clock, defaultValue = true, getter = { it.interfacePreferences.showClock }, @@ -898,33 +658,8 @@ sealed interface AppPreference<Pref, T> { summaryOff = R.string.disabled, ) - val BackdropStylePref = - AppChoicePreference<AppPreferences, BackdropStyle>( - title = R.string.backdrop_display, - defaultValue = BackdropStyle.BACKDROP_DYNAMIC_COLOR, - getter = { it.interfacePreferences.backdropStyle }, - setter = { prefs, value -> - prefs.updateInterfacePreferences { backdropStyle = value } - }, - displayValues = R.array.backdrop_style_options, - indexToValue = { BackdropStyle.forNumber(it) }, - valueToIndex = { it.number }, - ) - - val ManageMedia = - AppSwitchPreference<AppPreferences>( - title = R.string.show_media_management, - defaultValue = false, - getter = { it.interfacePreferences.enableMediaManagement }, - setter = { prefs, value -> - prefs.updateInterfacePreferences { enableMediaManagement = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val OneClickPause = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.one_click_pause, defaultValue = false, getter = { it.playbackPreferences.oneClickPause }, @@ -936,43 +671,13 @@ sealed interface AppPreference<Pref, T> { ) val SubtitleStyle = - AppDestinationPreference<AppPreferences>( + AppDestinationPreference( title = R.string.subtitle_style, - destination = Destination.SubtitleSettings(false), - ) - - val RefreshRateSwitching = - AppSwitchPreference<AppPreferences>( - title = R.string.refresh_rate_switching, - defaultValue = false, - getter = { it.playbackPreferences.refreshRateSwitching }, - setter = { prefs, value -> - prefs.updatePlaybackPreferences { - if (!value) resolutionSwitching = false - refreshRateSwitching = value - } - }, - summaryOn = R.string.automatic, - summaryOff = R.string.disabled, - ) - - val ResolutionSwitching = - AppSwitchPreference<AppPreferences>( - title = R.string.resolution_switching, - defaultValue = false, - getter = { it.playbackPreferences.resolutionSwitching }, - setter = { prefs, value -> - prefs.updatePlaybackPreferences { - if (value) refreshRateSwitching = true - resolutionSwitching = value - } - }, - summaryOn = R.string.automatic, - summaryOff = R.string.disabled, + destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), ) val PlayerBackendPref = - AppChoicePreference<AppPreferences, PlayerBackend>( + AppChoicePreference<PlayerBackend>( title = R.string.player_backend, defaultValue = PlayerBackend.EXO_PLAYER, getter = { it.playbackPreferences.playerBackend }, @@ -980,42 +685,12 @@ sealed interface AppPreference<Pref, T> { prefs.updatePlaybackPreferences { playerBackend = value } }, displayValues = R.array.player_backend_options, - subtitles = R.array.player_backend_options_subtitles, - indexToValue = { PlayerBackend.forNumber(it) ?: PlayerBackend.EXO_PLAYER }, - valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number }, - ) - - val ExternalPlayerApp = - AppStringPreference<AppPreferences>( - title = R.string.external_player, - defaultValue = "", - getter = { it.playbackPreferences.externalPlayer }, - setter = { prefs, value -> - prefs.updatePlaybackPreferences { externalPlayer = value } - }, - summary = null, - ) - - val ExoPlayerSettings = - AppDestinationPreference<AppPreferences>( - title = R.string.exoplayer_options, - destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER), - ) - - val MpvSettings = - AppDestinationPreference<AppPreferences>( - title = R.string.mpv_options, - destination = Destination.Settings(PreferenceScreenOption.MPV), - ) - - val HdrFormatSettings = - AppDestinationPreference<AppPreferences>( - title = R.string.hdr_formats, - destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS), + indexToValue = { PlayerBackend.forNumber(it) }, + valueToIndex = { it.number }, ) val MpvHardwareDecoding = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference( title = R.string.mpv_hardware_decoding, defaultValue = true, getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding }, @@ -1024,184 +699,6 @@ sealed interface AppPreference<Pref, T> { }, summary = R.string.disable_if_crash, ) - - val MpvGpuNext = - AppSwitchPreference<AppPreferences>( - title = R.string.mpv_use_gpu_next, - defaultValue = false, - getter = { it.playbackPreferences.mpvOptions.useGpuNext }, - setter = { prefs, value -> - prefs.updateMpvOptions { useGpuNext = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val MpvConfFile = - AppClickablePreference<AppPreferences>( - title = R.string.mpv_conf, - summary = null, - ) - - val DebugLogging = - AppSwitchPreference<AppPreferences>( - title = R.string.verbose_logging, - defaultValue = false, - getter = { DebugLogTree.INSTANCE.enabled }, - setter = { prefs, value -> - DebugLogTree.INSTANCE.enabled = value - prefs.update { debugLogging = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val SignInAuto = - AppSwitchPreference<AppPreferences>( - title = R.string.sign_in_auto, - defaultValue = true, - getter = { it.signInAutomatically }, - setter = { prefs, value -> - prefs.update { signInAutomatically = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val ProtectProfilePreference = - AppClickablePreference<AppPreferences>( - title = R.string.profile_protection, - ) - - val ImageDiskCacheSize = - AppSliderPreference<AppPreferences>( - title = R.string.image_cache_size, - defaultValue = 200, - min = 25, - max = 1_000, - interval = 25, - getter = { - it.advancedPreferences.imageDiskCacheSizeBytes / MEGA_BIT - }, - setter = { prefs, value -> - prefs.updateAdvancedPreferences { - imageDiskCacheSizeBytes = value * MEGA_BIT - } - }, - summarizer = { value -> - if (value != null) { - "$value MB" - } else { - null - } - }, - ) - - val LiveTvShowHeader = - AppSwitchPreference<AppPreferences>( - title = R.string.show_details, - defaultValue = true, - getter = { it.interfacePreferences.liveTvPreferences.showHeader }, - setter = { prefs, value -> - prefs.updateLiveTvPreferences { showHeader = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val LiveTvFavoriteChannelsBeginning = - AppSwitchPreference<AppPreferences>( - title = R.string.favorite_channels_at_beginning, - defaultValue = true, - getter = { it.interfacePreferences.liveTvPreferences.favoriteChannelsAtBeginning }, - setter = { prefs, value -> - prefs.updateLiveTvPreferences { favoriteChannelsAtBeginning = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val LiveTvChannelSortByWatched = - AppSwitchPreference<AppPreferences>( - title = R.string.sort_channels_recently_watched, - defaultValue = false, - getter = { it.interfacePreferences.liveTvPreferences.sortByRecentlyWatched }, - setter = { prefs, value -> - prefs.updateLiveTvPreferences { sortByRecentlyWatched = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - val LiveTvColorCodePrograms = - AppSwitchPreference<AppPreferences>( - title = R.string.color_code_programs, - defaultValue = true, - getter = { it.interfacePreferences.liveTvPreferences.colorCodePrograms }, - setter = { prefs, value -> - prefs.updateLiveTvPreferences { colorCodePrograms = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val SeerrIntegration = - AppClickablePreference<AppPreferences>( - title = R.string.seerr_integration, - getter = { }, - setter = { prefs, _ -> prefs }, - ) - - val QuickConnect = - AppClickablePreference<AppPreferences>( - title = R.string.quick_connect, - summary = R.string.quick_connect_summary, - getter = { }, - setter = { prefs, _ -> prefs }, - ) - - val SlideshowDuration = - AppSliderPreference<AppPreferences>( - title = R.string.slideshow_duration, - defaultValue = 5_000, - min = 1_000, - max = 30.seconds.inWholeMilliseconds, - interval = 250, - getter = { - it.photoPreferences.slideshowDuration - }, - setter = { prefs, value -> - prefs.updatePhotoPreferences { - slideshowDuration = value - } - }, - summarizer = { value -> - if (value != null) { - val seconds = value / 1000.0 - WholphinApplication.instance.resources.getString( - R.string.decimal_seconds, - seconds, - ) - } else { - null - } - }, - ) - - val SlideshowPlayVideos = - AppSwitchPreference<AppPreferences>( - title = R.string.play_videos_during_slideshow, - defaultValue = false, - getter = { it.photoPreferences.slideshowPlayVideos }, - setter = { prefs, value -> - prefs.updatePhotoPreferences { slideshowPlayVideos = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - val ScreensaverSettings = - AppDestinationPreference<AppPreferences>( - title = R.string.screensaver_settings, - destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), - ) } } @@ -1211,12 +708,13 @@ val basicPreferences = title = R.string.ui_interface, preferences = listOf( - AppPreference.SignInAuto, - AppPreference.RememberSelectedTab, + AppPreference.HomePageItems, + AppPreference.CombineContinueNext, + AppPreference.RewatchNextUp, AppPreference.PlayThemeMusic, + AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, - AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -1242,11 +740,7 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( - AppPreference.ProtectProfilePreference, - AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, - AppPreference.UserInterfaceLanguage, - AppPreference.UserProfileSettings, ), ), PreferenceGroup( @@ -1261,82 +755,15 @@ val basicPreferences = ), PreferenceGroup( title = R.string.more, - preferences = - buildList { - if (BuildConfig.DISCOVER_ENABLED) { - add(AppPreference.SeerrIntegration) - } - add(AppPreference.AdvancedSettings) - }, - ), - ) - -private val ExoPlayerSettings = - listOf( - AppPreference.FfmpegPreference, - AppPreference.DownMixStereo, - AppPreference.Ac3Supported, - AppPreference.AssSubtitleMode, - AppPreference.DirectPlayPgs, - AppPreference.DecodeAv1, - AppPreference.HdrFormatSettings, - ) - -private val HdrFormatSettings = - listOf( - AppPreference.Hdr10Override, - AppPreference.Hdr10PlusOverride, - AppPreference.DoviProfile5Override, - AppPreference.DoviProfile7Override, - AppPreference.DoviProfile8Override, - ) - -val ExoPlayerPreferences = - listOf( - PreferenceGroup( - title = R.string.exoplayer_options, - preferences = ExoPlayerSettings, - ), - ) - -val HdrFormatPreferences = - listOf( - PreferenceGroup( - title = R.string.hdr_formats, - preferences = HdrFormatSettings, - ), - ) - -private val MpvSettings = - listOf( - AppPreference.MpvHardwareDecoding, - AppPreference.MpvGpuNext, - AppPreference.MpvConfFile, - ) - -val MpvPreferences = - listOf( - PreferenceGroup( - title = R.string.mpv_options, - preferences = MpvSettings, - ), - ) - -val SkipSegmentPreferences = - listOf( - PreferenceGroup( - title = R.string.skip, preferences = listOf( - AppPreference.SkipIntros, - AppPreference.SkipOutros, - AppPreference.SkipCommercials, - AppPreference.SkipPreviews, - AppPreference.SkipRecaps, + AppPreference.AdvancedSettings, ), ), ) +val uiPreferences = listOf<PreferenceGroup>() + val advancedPreferences = buildList { add( @@ -1344,18 +771,10 @@ val advancedPreferences = title = R.string.ui_interface, preferences = listOf( - AppPreference.WolfskinScreens, - AppPreference.ConductarrUrl, - *if (com.github.damontecres.wholphin.BuildConfig.DEBUG) arrayOf(AppPreference.WolfskinFixtures) else emptyArray(), AppPreference.ShowClock, - AppPreference.BackdropStylePref, - AppPreference.ShowLogos, - AppPreference.ManageMedia, - AppPreference.DisplayTogglesPref, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, - AppPreference.SlideshowDuration, - AppPreference.SlideshowPlayVideos, + AppPreference.ControllerTimeout, ), ), ) @@ -1365,15 +784,13 @@ val advancedPreferences = preferences = listOf( AppPreference.OneClickPause, - AppPreference.ControllerTimeout, - AppPreference.CinemaMode, AppPreference.GlobalContentScale, - AppPreference.SkipSegments, - AppPreference.DpadSeekModePref, - AppPreference.MaxResolutionPref, + AppPreference.SkipIntros, + AppPreference.SkipOutros, + AppPreference.SkipCommercials, + AppPreference.SkipPreviews, + AppPreference.SkipRecaps, AppPreference.MaxBitrate, - AppPreference.RefreshRateSwitching, - AppPreference.ResolutionSwitching, AppPreference.PlaybackDebugInfo, ), ), @@ -1386,22 +803,17 @@ val advancedPreferences = listOf( ConditionalPreferences( { it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER }, - ExoPlayerSettings, - ), - ConditionalPreferences( - { it.playbackPreferences.playerBackend == PlayerBackend.MPV }, - MpvSettings, - ), - ConditionalPreferences( - { it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV }, listOf( - AppPreference.ExoPlayerSettings, - AppPreference.MpvSettings, + AppPreference.FfmpegPreference, + AppPreference.DownMixStereo, + AppPreference.Ac3Supported, + AppPreference.DirectPlayAss, + AppPreference.DirectPlayPgs, ), ), ConditionalPreferences( - { it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER }, - listOf(AppPreference.ExternalPlayerApp), + { it.playbackPreferences.playerBackend == PlayerBackend.MPV }, + listOf(AppPreference.MpvHardwareDecoding), ), ), ), @@ -1423,75 +835,25 @@ val advancedPreferences = title = R.string.more, preferences = listOf( - AppPreference.QuickConnect, AppPreference.SendAppLogs, AppPreference.SendCrashReports, - AppPreference.DebugLogging, - AppPreference.ImageDiskCacheSize, AppPreference.ClearImageCache, - ExperimentalPreference.Enable, AppPreference.OssLicenseInfo, ), - conditionalPreferences = - listOf( - ConditionalPreferences( - condition = { it.experimentalPreferences.enabled }, - preferences = listOf(ExperimentalPreference.ExperimentalSettings), - ), - ), ), ) } -val liveTvPreferences = - listOf( - AppPreference.LiveTvShowHeader, - AppPreference.LiveTvFavoriteChannelsBeginning, - AppPreference.LiveTvChannelSortByWatched, - AppPreference.LiveTvColorCodePrograms, - ) - -val screensaverPreferences = - listOf( - PreferenceGroup( - title = R.string.screensaver, - preferences = - listOf( - ScreensaverPreference.Enabled, - ScreensaverPreference.StartDelay, - ScreensaverPreference.Duration, - ScreensaverPreference.ShowClock, - ScreensaverPreference.Animate, - ScreensaverPreference.MaxAge, - ScreensaverPreference.ItemTypes, - ScreensaverPreference.Start, - ), - ), - PreferenceGroup( - title = R.string.dim_screen, - preferences = - listOf( - ScreensaverPreference.DimToggle, - ScreensaverPreference.DimPercentage, - ), - ), - ) - -data class AppSwitchPreference<Pref>( +data class AppSwitchPreference( @get:StringRes override val title: Int, override val defaultValue: Boolean, - override val getter: (prefs: Pref) -> Boolean, - override val setter: (prefs: Pref, value: Boolean) -> Pref, - val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, + override val getter: (prefs: AppPreferences) -> Boolean, + override val setter: (prefs: AppPreferences, value: Boolean) -> AppPreferences, + val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, -) : AppPreference<Pref, Boolean> { - override fun validate( - prefs: Pref, - value: Boolean, - ): PreferenceValidation = validator.invoke(prefs, value) - +) : AppPreference<Boolean> { override fun summary( context: Context, value: Boolean?, @@ -1503,70 +865,70 @@ data class AppSwitchPreference<Pref>( } } -open class AppStringPreference<Pref>( +open class AppStringPreference( @param:StringRes override val title: Int, override val defaultValue: String, - override val getter: (Pref) -> String, - override val setter: (Pref, String) -> Pref, + override val getter: (AppPreferences) -> String, + override val setter: (AppPreferences, String) -> AppPreferences, @param:StringRes val summary: Int?, -) : AppPreference<Pref, String> { +) : AppPreference<String> { override fun summary( context: Context, value: String?, ): String? = summary?.let { context.getString(it) } ?: value } -data class AppChoicePreference<Pref, T>( +data class AppChoicePreference<T>( @param:StringRes override val title: Int, override val defaultValue: T, @param:ArrayRes val displayValues: Int, val indexToValue: (index: Int) -> T, val valueToIndex: (T) -> Int, - override val getter: (prefs: Pref) -> T, - override val setter: (prefs: Pref, value: T) -> Pref, + override val getter: (prefs: AppPreferences) -> T, + override val setter: (prefs: AppPreferences, value: T) -> AppPreferences, @param:StringRes val summary: Int? = null, - @param:ArrayRes val subtitles: Int? = null, -) : AppPreference<Pref, T> +) : AppPreference<T> -data class AppMultiChoicePreference<Pref, T>( +data class AppMultiChoicePreference<T>( @param:StringRes override val title: Int, override val defaultValue: List<T>, val allValues: List<T>, @param:ArrayRes val displayValues: Int, - override val getter: (prefs: Pref) -> List<T>, - override val setter: (prefs: Pref, value: List<T>) -> Pref, + override val getter: (prefs: AppPreferences) -> List<T>, + override val setter: (prefs: AppPreferences, value: List<T>) -> AppPreferences, @param:StringRes val summary: Int? = null, - @param:ArrayRes val displayValuesSubtitles: Int? = null, -) : AppPreference<Pref, List<T>> + val toSharedPrefs: (T) -> String, + val fromSharedPrefs: (String) -> T?, +) : AppPreference<List<T>> -data class AppClickablePreference<Pref>( +data class AppClickablePreference( @param:StringRes override val title: Int, override val defaultValue: Unit = Unit, - override val getter: (prefs: Pref) -> Unit = { }, - override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs }, + override val getter: (prefs: AppPreferences) -> Unit = { }, + override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, @param:StringRes val summary: Int? = null, -) : AppPreference<Pref, Unit> { +) : AppPreference<Unit> { override fun summary( context: Context, value: Unit?, ): String? = summary?.let { context.getString(it) } } -data class AppDestinationPreference<Pref>( +data class AppDestinationPreference( @param:StringRes override val title: Int, override val defaultValue: Unit = Unit, - override val getter: (prefs: Pref) -> Unit = { }, - override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs }, + override val getter: (prefs: AppPreferences) -> Unit = { }, + override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, @param:StringRes val summary: Int? = null, val destination: Destination, -) : AppPreference<Pref, Unit> { +) : AppPreference<Unit> { override fun summary( context: Context, value: Unit?, ): String? = summary?.let { context.getString(it) } } -class AppSliderPreference<Pref>( +class AppSliderPreference( @param:StringRes override val title: Int, override val defaultValue: Long, /** @@ -1578,11 +940,11 @@ class AppSliderPreference<Pref>( */ val max: Long = 100, val interval: Int = 1, - override val getter: (prefs: Pref) -> Long, - override val setter: (prefs: Pref, value: Long) -> Pref, + override val getter: (prefs: AppPreferences) -> Long, + override val setter: (prefs: AppPreferences, value: Long) -> AppPreferences, @param:StringRes val summary: Int? = null, val summarizer: ((Long?) -> String?)? = null, -) : AppPreference<Pref, Long> { +) : AppPreference<Long> { override fun summary( context: Context, value: Long?, 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 85f8aade..a4c38f66 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -5,7 +5,6 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException -import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -21,9 +20,6 @@ class AppPreferencesSerializer .apply { updateUrl = AppPreference.UpdateUrl.defaultValue autoCheckForUpdates = AppPreference.AutoCheckForUpdates.defaultValue - sendCrashReports = AppPreference.SendCrashReports.defaultValue - debugLogging = AppPreference.DebugLogging.defaultValue - signInAutomatically = AppPreference.SignInAuto.defaultValue playbackPreferences = PlaybackPreferences @@ -51,11 +47,6 @@ class AppPreferencesSerializer AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue playerBackend = AppPreference.PlayerBackendPref.defaultValue - refreshRateSwitching = - AppPreference.RefreshRateSwitching.defaultValue - resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue - cinemaMode = AppPreference.CinemaMode.defaultValue - dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides @@ -63,12 +54,10 @@ class AppPreferencesSerializer .apply { ac3Supported = AppPreference.Ac3Supported.defaultValue downmixStereo = AppPreference.DownMixStereo.defaultValue -// directPlayAss = AppPreference.DirectPlayAss.defaultValue + directPlayAss = AppPreference.DirectPlayAss.defaultValue directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue - assPlaybackMode = - AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = @@ -77,7 +66,6 @@ class AppPreferencesSerializer .apply { enableHardwareDecoding = AppPreference.MpvHardwareDecoding.defaultValue - useGpuNext = AppPreference.MpvGpuNext.defaultValue }.build() }.build() homePagePreferences = @@ -86,7 +74,7 @@ class AppPreferencesSerializer .apply { maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue - maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + combineContinueNext = AppPreference.CombineContinueNext.defaultValue }.build() interfacePreferences = InterfacePreferences @@ -97,18 +85,6 @@ class AppPreferencesSerializer navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue showClock = AppPreference.ShowClock.defaultValue - backdropStyle = AppPreference.BackdropStylePref.defaultValue - showLogos = AppPreference.ShowLogos.defaultValue - clearDisplayToggles() - addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue) - - searchPreferences = - SearchPreferences - .newBuilder() - .apply { - combinedSearchResults = false - showVoiceSearchButton = true - }.build() subtitlesPreferences = SubtitlePreferences @@ -116,78 +92,6 @@ class AppPreferencesSerializer .apply { resetSubtitles() }.build() - hdrSubtitlesPreferences = - SubtitlePreferences - .newBuilder() - .apply { - resetSubtitles() - }.build() - - liveTvPreferences = - LiveTvPreferences - .newBuilder() - .apply { - showHeader = AppPreference.LiveTvShowHeader.defaultValue - favoriteChannelsAtBeginning = - AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue - sortByRecentlyWatched = - AppPreference.LiveTvChannelSortByWatched.defaultValue - colorCodePrograms = - AppPreference.LiveTvColorCodePrograms.defaultValue - }.build() - - screensaverPreference = - ScreensaverPreferences - .newBuilder() - .apply { - startDelay = ScreensaverPreference.DEFAULT_START_DELAY - duration = ScreensaverPreference.DEFAULT_DURATION - animate = ScreensaverPreference.Animate.defaultValue - maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE - showClock = ScreensaverPreference.ShowClock.defaultValue - clearItemTypes() - addItemTypes(BaseItemKind.MOVIE.serialName) - addItemTypes(BaseItemKind.SERIES.serialName) - }.build() - }.build() - - advancedPreferences = - AdvancedPreferences - .newBuilder() - .apply { - imageDiskCacheSizeBytes = - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT - }.build() - - photoPreferences = - PhotoPreferences - .newBuilder() - .apply { - slideshowDuration = AppPreference.SlideshowDuration.defaultValue - slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue - }.build() - - musicPreferences = - MusicPreferences - .newBuilder() - .apply { - showBackdrop = true - showLyrics = true - showAlbumArt = true - }.build() - - experimentalPreferences = - ExperimentalPreferences - .newBuilder() - .apply { - enabled = false - videoTunnelingEnabled = false - preferAc3Surround = ExperimentalPreference.PreferAc3ForSurround.defaultValue - disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue - preferDolbyVisionOverHdr10Plus = - ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue - convertDolbyVisionProfile7 = - ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue }.build() }.build() @@ -232,46 +136,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences interfacePreferences = interfacePreferences.toBuilder().apply(block).build() } -inline fun AppPreferences.updateSearchPreferences(block: SearchPreferences.Builder.() -> Unit): AppPreferences = - updateInterfacePreferences { - searchPreferences = searchPreferences.toBuilder().apply(block).build() - } - inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences = updateInterfacePreferences { subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() } -inline fun AppPreferences.updateLiveTvPreferences(block: LiveTvPreferences.Builder.() -> Unit): AppPreferences = - updateInterfacePreferences { - liveTvPreferences = liveTvPreferences.toBuilder().apply(block).build() - } - -inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.Builder.() -> Unit): AppPreferences = - update { - advancedPreferences = advancedPreferences.toBuilder().apply(block).build() - } - -inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences = - update { - photoPreferences = photoPreferences.toBuilder().apply(block).build() - } - -inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = - updateInterfacePreferences { - screensaverPreference = screensaverPreference.toBuilder().apply(block).build() - } - -inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder.() -> Unit): AppPreferences = - update { - 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() @@ -283,7 +152,4 @@ fun SubtitlePreferences.Builder.resetSubtitles() { backgroundColor = SubtitleSettings.BackgroundColor.defaultValue.toArgb() backgroundOpacity = SubtitleSettings.BackgroundOpacity.defaultValue.toInt() backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue - margin = SubtitleSettings.Margin.defaultValue.toInt() - edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() - imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt deleted file mode 100644 index 46c75636..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ /dev/null @@ -1,120 +0,0 @@ -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 PreferDolbyVisionOverHdr10Plus = - AppSwitchPreference<AppPreferences>( - title = R.string.prefer_dovi_over_hdr10_plus, - defaultValue = false, - getter = { it.experimentalPreferences.preferDolbyVisionOverHdr10Plus }, - setter = { prefs, value -> - prefs.updateExperimentalPreferences { preferDolbyVisionOverHdr10Plus = value } - }, - summary = R.string.prefer_dovi_over_hdr10_plus_summary, - ) - - val ConvertDolbyVisionProfile7 = - AppSwitchPreference<AppPreferences>( - title = R.string.convert_dovi_profile_7, - defaultValue = false, - getter = { it.experimentalPreferences.convertDolbyVisionProfile7 }, - setter = { prefs, value -> - prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value } - }, - summary = R.string.convert_dovi_profile_7_summary, - ) - - 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.PreferDolbyVisionOverHdr10Plus, - ExperimentalPreference.ConvertDolbyVisionProfile7, - 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 478bffb6..0000878b 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,19 +1,5 @@ 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 @@ -24,48 +10,3 @@ 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/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt deleted file mode 100644 index df0b6823..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.damontecres.wholphin.preferences - -import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType - -// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt - -/** - * HDR formats that the user can override in the device profile. - */ -enum class HdrFormat( - val getter: (AppPreferences) -> HdrOverrideMode, - val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set<String>, -) { - HDR10( - getter = { it.playbackPreferences.overrides.hdr10Override }, - rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) }, - ), - HDR10_PLUS( - getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, - rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) }, - ), - DOVI_PROFILE_5( - getter = { it.playbackPreferences.overrides.doviProfile5Override }, - rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) }, - ), - DOVI_PROFILE_7( - getter = { it.playbackPreferences.overrides.doviProfile7Override }, - rangeTypeNames = { jellyfinTenEleven -> - if (jellyfinTenEleven) { - setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) - } else { - emptySet() - } - }, - ), - DOVI_PROFILE_8( - getter = { it.playbackPreferences.overrides.doviProfile8Override }, - rangeTypeNames = { jellyfinTenEleven -> - buildSet { - add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) - } - }, - ), -} - -/** - * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode] - */ -fun AppPreferences.getHdrRangeTypesFor( - mode: HdrOverrideMode, - jellyfinTenEleven: Boolean, -): Set<String> = - HdrFormat.entries - .filter { it.getter(this) == mode } - .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt deleted file mode 100644 index 8b389927..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.damontecres.wholphin.preferences - -enum class MaxResolution( - val value: Int, -) { - NO_LIMIT(0), - ULTRA_HD_8K(4320), - ULTRA_HD_4K(2160), - FULL_HD(1080), - HD(720), - PAL_SD(576), - NTSC_SD(480), - LOW_DEF(360), - ; - - companion object { - fun fromValue(value: Int): MaxResolution = entries.firstOrNull { it.value == value } ?: NO_LIMIT - } -} 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 deleted file mode 100644 index 04fb4a63..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.github.damontecres.wholphin.preferences - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication -import org.jellyfin.sdk.model.api.BaseItemKind -import kotlin.time.Duration.Companion.milliseconds - -object ScreensaverPreference { - val Enabled = - AppSwitchPreference<AppPreferences>( - title = R.string.in_app_screensaver, - defaultValue = false, - getter = { it.interfacePreferences.screensaverPreference.enabled }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { enabled = value } - }, - summaryOn = R.string.yes, - summaryOff = R.string.no, - ) - - const val DEFAULT_START_DELAY = 15 * 60_000L - private val startDelayValues = - listOf( - 30_000L, - 60_000L, - 2 * 60_000L, - 5 * 60_000L, - 10 * 60_000L, - 15 * 60_000L, - 30 * 60_000L, - 60 * 60_000L, - ) - val StartDelay = - AppSliderPreference<AppPreferences>( - title = R.string.start_after, - defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), - min = 0, - max = startDelayValues.size - 1L, - interval = 1, - getter = { - startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() - }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { - startDelay = startDelayValues[value.toInt()] - } - }, - summarizer = { value -> - if (value != null) { - val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY - v.milliseconds.toString() - } else { - null - } - }, - ) - - const val DEFAULT_DURATION = 30_000L - private val durationValues = - listOf( - 15_000L, - 30_000L, - 60_000L, - 2 * 60_000L, - ) - val Duration = - AppSliderPreference<AppPreferences>( - title = R.string.duration, - defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), - min = 0, - max = durationValues.size - 1L, - interval = 1, - getter = { - durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() - }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { - duration = durationValues[value.toInt()] - } - }, - summarizer = { value -> - if (value != null) { - val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION - v.milliseconds.toString() - } else { - null - } - }, - ) - - val ShowClock = - AppSwitchPreference<AppPreferences>( - title = R.string.show_clock, - defaultValue = AppPreference.ShowClock.defaultValue, - getter = { it.interfacePreferences.screensaverPreference.showClock }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { showClock = value } - }, - summaryOn = R.string.yes, - summaryOff = R.string.no, - ) - - val Animate = - AppSwitchPreference<AppPreferences>( - title = R.string.animate, - defaultValue = true, - getter = { it.interfacePreferences.screensaverPreference.animate }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { animate = value } - }, - summaryOn = R.string.enabled, - summaryOff = R.string.disabled, - ) - - const val DEFAULT_MAX_AGE = 16 - private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) - val MaxAge = - AppSliderPreference<AppPreferences>( - title = R.string.max_age_rating, - defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), - min = 0, - max = maxAgeValues.size - 1L, - interval = 1, - getter = { - it.interfacePreferences.screensaverPreference.maxAgeFilter - .takeIf { it >= 0 } - ?.let { maxAgeValues.indexOf(it).toLong() } - ?: maxAgeValues.lastIndex.toLong() - }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { - maxAgeFilter = maxAgeValues[value.toInt()] - } - }, - summarizer = { value -> - when (value) { - null -> { - null - } - - maxAgeValues.lastIndex.toLong() -> { - WholphinApplication.instance.getString(R.string.no_max) - } - - 0L -> { - WholphinApplication.instance.getString(R.string.for_all_ages) - } - - else -> { - WholphinApplication.instance.getString( - R.string.up_to_age, - maxAgeValues[value.toInt()].toString(), - ) - } - } - }, - ) - - val ItemTypes = - AppMultiChoicePreference<AppPreferences, BaseItemKind>( - title = R.string.include_types, - summary = R.string.include_types_summary, - defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), - displayValues = R.array.screensaver_item_types, - getter = { - it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> - BaseItemKind.fromName(type) - } - }, - setter = { prefs, value -> - prefs.updateScreensaverPreferences { - clearItemTypes() - addAllItemTypes(value.map { it.serialName }) - } - }, - ) - - val 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 c90cd00a..520a12e8 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,6 +1,5 @@ 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 @@ -9,7 +8,7 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, - val userPreferences: JellyfinUserPreferences?, + val userConfig: UserConfiguration, ) 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 deleted file mode 100644 index 31d0e98c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserProfileSettings.kt +++ /dev/null @@ -1,70 +0,0 @@ -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 17312378..ba7de582 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 @@ -1,95 +1,32 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.content.pm.PackageInfo import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.JellyfinServerDao -import com.github.damontecres.wholphin.data.MostRecentServerProvider -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.SeriesTrackChoiceDao -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.RememberedTab -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.ExperimentalPreference -import com.github.damontecres.wholphin.preferences.HdrOverrideMode -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 -import com.github.damontecres.wholphin.preferences.updateMpvOptions -import com.github.damontecres.wholphin.preferences.updateMusicPreferences -import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides -import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences -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 kotlinx.coroutines.flow.firstOrNull -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 -/** - * Handles any changes needed when the app in upgraded on the device such as setting new preferences - */ @Singleton class AppUpgradeHandler @Inject constructor( @param:ApplicationContext private val context: Context, private val appPreferences: DataStore<AppPreferences>, - private val seerrServerDao: SeerrServerDao, - private val serverDao: JellyfinServerDao, - private val rememberedTabDao: RememberedTabDao, - private val mostRecentServerProvider: MostRecentServerProvider, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, - private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { - val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) - val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) - - fun needUpgrade(): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) - val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) - - val newVersion = pkgInfo.versionName!! - val newVersionCode = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - pkgInfo.longVersionCode - } else { - pkgInfo.versionCode.toLong() - } - Timber.i( - "App versions: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", - ) - return newVersion != previousVersion || newVersionCode != previousVersionCode - } - suspend fun run() { - Timber.i("App upgrade started") + val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0) val prefs = PreferenceManager.getDefaultSharedPreferences(context) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) @@ -101,29 +38,30 @@ class AppUpgradeHandler } else { pkgInfo.versionCode.toLong() } - // Store the previous and new version info - prefs.edit(true) { - putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) - putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) - putString(VERSION_NAME_CURRENT_KEY, newVersion) - putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) - } - try { - copySubfont(true) - upgradeApp( - Version.fromString(previousVersion ?: "0.0.0"), - Version.fromString(newVersion), - appPreferences, + if (newVersion != previousVersion || newVersionCode != previousVersionCode) { + Timber.i( + "App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode", ) - } catch (ex: Exception) { - Timber.e(ex, "Exception during app upgrade") + prefs.edit(true) { + putString(VERSION_NAME_PREVIOUS_KEY, previousVersion) + putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode) + putString(VERSION_NAME_CURRENT_KEY, newVersion) + putLong(VERSION_CODE_CURRENT_KEY, newVersionCode) + } + try { + copySubfont(true) + upgradeApp( + context, + Version.Companion.fromString(previousVersion ?: "0.0.0"), + Version.Companion.fromString(newVersion), + appPreferences, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception during app upgrade") + } } - Timber.i("App upgrade complete") } - /** - * Copies the font file used by MPV subtitles to the app's files directory - */ fun copySubfont(overwrite: Boolean) { try { val fontFileName = "subfont.ttf" @@ -151,360 +89,39 @@ class AppUpgradeHandler const val VERSION_NAME_CURRENT_KEY = "version.current.name" const val VERSION_CODE_CURRENT_KEY = "version.current.code" } + } - /** - * Perform any needed upgrades - */ - suspend fun upgradeApp( - previous: Version, - current: Version, - appPreferences: DataStore<AppPreferences>, - ) { - if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { - appPreferences.updateData { - it.updatePlaybackOverrides { - ac3Supported = AppPreference.Ac3Supported.defaultValue - downmixStereo = AppPreference.DownMixStereo.defaultValue -// directPlayAss = AppPreference.DirectPlayAss.defaultValue - directPlayPgs = AppPreference.DirectPlayPgs.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - showClock = AppPreference.ShowClock.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { - PreferencesViewModel.resetSubtitleSettings(appPreferences) - } - if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - margin = SubtitleSettings.Margin.defaultValue.toInt() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { - appPreferences.updateData { - it.updateAdvancedPreferences { - if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { - imageDiskCacheSizeBytes = - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT - } - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = AppPreference.MpvGpuNext.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { - appPreferences.updateData { - it.update { - signInAutomatically = AppPreference.SignInAuto.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - if (edgeThickness < 1) { - edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() - } - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { - appPreferences.updateData { - it.updateLiveTvPreferences { - showHeader = AppPreference.LiveTvShowHeader.defaultValue - favoriteChannelsAtBeginning = - AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue - sortByRecentlyWatched = - AppPreference.LiveTvChannelSortByWatched.defaultValue - colorCodePrograms = - AppPreference.LiveTvColorCodePrograms.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { - if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false - } - } - } - } - - // TODO temporarily disabled until some MPV bugs are fixed -// if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { -// appPreferences.updateData { -// it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } -// } -// showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) -// } - - if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - subtitlesPreferences = - subtitlesPreferences - .toBuilder() - .apply { - imageSubtitleOpacity = - SubtitleSettings.ImageOpacity.defaultValue.toInt() - }.build() - // Copy current subtitle prefs as HDR ones - hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { - appPreferences.updateData { - it.updatePhotoPreferences { - slideshowDuration = AppPreference.SlideshowDuration.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { - appPreferences.updateData { - it.updateHomePagePreferences { - maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { - appPreferences.updateData { - it.updateScreensaverPreferences { - startDelay = ScreensaverPreference.DEFAULT_START_DELAY - duration = ScreensaverPreference.DEFAULT_DURATION - animate = ScreensaverPreference.Animate.defaultValue - maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE - clearItemTypes() - addItemTypes(BaseItemKind.MOVIE.serialName) - addItemTypes(BaseItemKind.SERIES.serialName) - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) { - seerrServerDao.getServers().forEach { - val server = it.server - seerrServerDao.updateServer( - server.copy(url = migrateSeerrUrl(server.url)), - ) - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) { - appPreferences.updateData { - it.updateMusicPreferences { - showBackdrop = true - showLyrics = true - showAlbumArt = true - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - showLogos = AppPreference.ShowLogos.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.6.2-1-g0"))) { - appPreferences.updateData { - it.updatePlaybackOverrides { - assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.6.2-18-g0"))) { - appPreferences.updateData { - it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.6.4-8-g0"))) { - appPreferences.updateData { - it.updateSearchPreferences { showVoiceSearchButton = true } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.6.4-26-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - clearDisplayToggles() - addAllDisplayToggles(AppPreference.DisplayTogglesPref.defaultValue) - } - } - } - - 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 - } - } - } - - if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) { - try { - val prefs = appPreferences.data.firstOrNull() - val serverId = prefs?.currentServerId?.toUUIDOrNull() - val userId = prefs?.currentUserId?.toUUIDOrNull() - if (serverId != null && userId != null) { - val server = serverDao.getServer(serverId) - val user = server?.users?.firstOrNull { it.id == userId } - if (server != null && user != null) { - Timber.i("Migrating most recent server") - mostRecentServerProvider.save(CurrentUser(server.server, user)) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error during migration for most recent server") - } - appPreferences.updateData { - it.update { - currentServerId = "" - currentUserId = "" - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) { - // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward - appPreferences.updateData { - if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) { - it.updatePlaybackOverrides { - doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE - directPlayDolbyVisionEL = false - } - } else { - it - } - } - } - - if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) { - val count = seriesTrackChoiceDao.count() - if (count == 0) { - var offset = 0 - while (true) { - val plcs = playbackLanguageChoiceDao.getAll(100, offset) - if (plcs.isEmpty()) break - offset += plcs.size - - val stcs = plcs.flatMap(SeriesTrackChoice::from) - Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size) - seriesTrackChoiceDao.save(stcs) - } - } +suspend fun upgradeApp( + context: Context, + previous: Version, + current: Version, + appPreferences: DataStore<AppPreferences>, +) { + if (previous.isEqualOrBefore(Version.Companion.fromString("0.1.0-2-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + ac3Supported = AppPreference.Ac3Supported.defaultValue + downmixStereo = AppPreference.DownMixStereo.defaultValue + directPlayAss = AppPreference.DirectPlayAss.defaultValue + directPlayPgs = AppPreference.DirectPlayPgs.defaultValue } } } + if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.3-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue + } + } + } + if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.5-11-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showClock = AppPreference.ShowClock.defaultValue + } + } + } + if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.7-1-g0"))) { + PreferencesViewModel.resetSubtitleSettings(appPreferences) + } +} 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 deleted file mode 100644 index 66ef284c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import android.graphics.Bitmap -import android.util.LruCache -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.isSpecified -import androidx.core.graphics.drawable.toBitmap -import androidx.datastore.core.DataStore -import androidx.palette.graphics.Palette -import coil3.asDrawable -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import coil3.request.allowHardware -import coil3.request.bitmapConfig -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.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Stores state for the backdrop of the app shown on non-full screen pages - * - * This is usually the backdrop of the currently focused media - */ -@Singleton -@OptIn(FlowPreview::class) -class BackdropService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val imageUrlService: ImageUrlService, - private val preferences: DataStore<AppPreferences>, - ) { - private val extractedColorCache = LruCache<String, ExtractedColors>(50) - - private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE) - val backdropFlow = _backdropFlow - - /** - * Update the backdrop to use the specified item - */ - suspend fun submit(item: BaseItem) = - withContext(WholphinDispatchers.IO) { - val imageUrl = - if (item.type == BaseItemKind.GENRE) { - item.imageUrlOverride - } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } - submit(item.id.toString(), imageUrl) - } - - /** - * Update the backdrop to use the specified discovered item - */ - suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) - - /** - * Update the backdrop to use the specified URL - */ - suspend fun submit( - itemId: String, - imageUrl: String?, - ) = withContext(WholphinDispatchers.IO) { - if (backdropFlow.firstOrNull()?.imageUrl != imageUrl || imageUrl == null) { - _backdropFlow.update { - it.copy( - itemId = itemId, - imageUrl = null, - ) - } - extractColors(itemId, imageUrl) - } - } - - /** - * Remove the backdrop, such as when switching pages - */ - suspend fun clearBackdrop() { - _backdropFlow.update { - BackdropResult.NONE - } - } - - private suspend fun extractColors( - itemId: String, - imageUrl: String?, - ) { - delay(500) - val backdropStyle = - preferences.data - .firstOrNull() - ?.interfacePreferences - ?.backdropStyle - val dynamicEnabled = - backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || - backdropStyle == BackdropStyle.UNRECOGNIZED - val colors = - if (dynamicEnabled) { - extractColorsFromBackdrop(imageUrl) - } else { - ExtractedColors.DEFAULT - } - _backdropFlow.update { - if (it.itemId == itemId) { - BackdropResult( - itemId = itemId, - imageUrl = imageUrl, - primaryColor = colors?.primary ?: it.primaryColor, - secondaryColor = colors?.secondary ?: it.secondaryColor, - tertiaryColor = colors?.tertiary ?: it.tertiaryColor, - ) - } else { - it - } - } - } - - suspend fun extractColorsFromBackdrop(imageUrl: String?): ExtractedColors? = - withContext(WholphinDispatchers.IO) { - if (imageUrl.isNullOrBlank()) { - return@withContext null - } - extractedColorCache.get(imageUrl)?.let { - return@withContext it - } - - try { - val loader = context.imageLoader - val request = - ImageRequest - .Builder(context) - .data(imageUrl) - .allowHardware(false) - .bitmapConfig(Bitmap.Config.ARGB_8888) - .build() - - val result = loader.execute(request) - if (result is SuccessResult) { - val drawable = result.image.asDrawable(context.resources) - val bitmap = drawable.toBitmap(config = Bitmap.Config.ARGB_8888) - extractColorsFromBitmap(bitmap).also { - extractedColorCache.put(imageUrl, it) - } - } else { - ExtractedColors.DEFAULT - } - } catch (e: Exception) { - Timber.e(e, "Error extracting colors from URL: $imageUrl") - ExtractedColors.DEFAULT - } - } - - /** - * Helper function to determine if a color is "cool" (blue/purple/green) vs "warm" (red/orange/yellow) - * - * Cool colors have more blue/green than red - */ - private val Palette.Swatch.coolColor: Boolean - get() { - val r = (rgb shr 16) and 0xFF - val g = (rgb shr 8) and 0xFF - val b = rgb and 0xFF - return b > r && (b + g) > (r * 1.5f) - } - - private fun toColor( - swatch: Palette.Swatch?, - alpha: Float, - ): Color = swatch?.rgb?.let(::Color)?.copy(alpha = alpha) ?: Color.Transparent - - /** - * Extracts colors from a bitmap using Android's Palette API. - * - * - Primary (Bottom-Right): darkVibrant -> darkMuted -> default - * - Secondary (Top-Left): Smart selection based on color temperature (prefers cool colors) - * - Tertiary (Top-Right): vibrant -> lightVibrant -> default - * - * @param bitmap The bitmap to extract colors from - * @return ExtractedColors containing primary, secondary, and tertiary colors - */ - private suspend fun extractColorsFromBitmap(bitmap: Bitmap): ExtractedColors = - try { - val palette = Palette.from(bitmap).generate() - - val vibrant = palette.vibrantSwatch - val darkVibrant = palette.darkVibrantSwatch - val lightVibrant = palette.lightVibrantSwatch - val muted = palette.mutedSwatch - val darkMuted = palette.darkMutedSwatch - val lightMuted = palette.lightMutedSwatch - val dominant = palette.dominantSwatch - - // Primary (Bottom-Right) - val primaryColor = toColor(darkVibrant ?: darkMuted, .4f) - - // Secondary (Top-Left): Smart selection based on color properties - // If Vibrant is cool (blue/purple), use it. If Vibrant is warm (yellow/orange) and Muted is cool, use Muted. - // This ensures we get cool tones (blue/purple) for top-left when available - val secondaryColor = - when { - vibrant != null && vibrant.coolColor -> vibrant - muted != null && muted.coolColor -> muted - vibrant != null -> vibrant - muted != null -> muted - else -> null - }.let { toColor(it, .4f) } - - // Tertiary (Top-Right under image) - val tertiaryColor = toColor(vibrant ?: lightVibrant, .35f) - - Timber.v( - "Colors extracted: primary=%s, secondary=%s, tertiary=%s", - primaryColor, - secondaryColor, - tertiaryColor, - ) - ExtractedColors(primaryColor, secondaryColor, tertiaryColor) - } catch (e: Exception) { - Timber.e(e, "Error extracting palette colors") - ExtractedColors.DEFAULT - } - - fun clearColorCache() { - extractedColorCache.evictAll() - } - } - -/** - * The result from determining the backdrop URL and extracted colors for the dynamic backdrop - */ -data class BackdropResult( - val itemId: String?, - val imageUrl: String?, - val primaryColor: Color = Color.Unspecified, - val secondaryColor: Color = Color.Unspecified, - val tertiaryColor: Color = Color.Unspecified, -) { - val hasColors: Boolean = - primaryColor.isSpecified || - secondaryColor.isSpecified || - tertiaryColor.isSpecified - - companion object { - val NONE = - BackdropResult( - null, - null, - ) - } -} - -/** - * The colors extracted from an image - */ -data class ExtractedColors( - val primary: Color, - val secondary: Color, - val tertiary: Color, -) { - companion object { - val DEFAULT = ExtractedColors(Color.Unspecified, Color.Unspecified, Color.Unspecified) - } -} 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 ceb84695..16c12fca 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 @@ -2,19 +2,18 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.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 com.google.common.cache.CacheLoader 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 @@ -29,9 +28,6 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -/** - * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. - */ @Singleton class DatePlayedService @Inject @@ -44,20 +40,20 @@ class DatePlayedService .newBuilder() .maximumSize(AppPreference.HomePageItems.max) .expireAfterWrite(2, TimeUnit.HOURS) - .build<SeriesItemId, Deferred<LocalDateTime>>() + .build<SeriesItemId, Deferred<LocalDateTime>>( + object : CacheLoader<SeriesItemId, Deferred<LocalDateTime>>() { + override fun load(key: SeriesItemId): Deferred<LocalDateTime> = getLastPlayed(key) + }, + ) - private fun getLastPlayed( - seriesId: UUID, - item: BaseItem, - ): Deferred<LocalDateTime> { + private fun getLastPlayed(key: SeriesItemId): Deferred<LocalDateTime> { val request = GetEpisodesRequest( - seriesId = seriesId, - adjacentTo = item.id, + seriesId = key.seriesId, + adjacentTo = key.itemId, limit = 1, ) - return scope.async(WholphinDispatchers.IO) { - val premiereDate = item.data.premiereDate + return scope.async(Dispatchers.IO) { try { val result = GetEpisodesRequestHandler @@ -65,50 +61,24 @@ class DatePlayedService api, request, ).content.items - .firstOrNull() - ?.userData - ?.lastPlayedDate ?: LocalDateTime.MIN - if (premiereDate != null && result.isBefore(premiereDate)) { - premiereDate - } else { - result - } + result.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN } catch (ex: InvalidStatusException) { - Timber.w( - "Error fetching series=%s, item=%s: %s", - seriesId, - item.id, - ex.localizedMessage, - ) - LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN) + Timber.w("Error fetching %s: %s", key, ex.localizedMessage) + LocalDateTime.MIN } } } - /** - * Get the logical timestamp when the item was last played. - * - * This is calculated using lastest of the actual last played timestamp of the item, - * the previous episode's last played timestamp, and the episode's premiere date - */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = - withContext(WholphinDispatchers.IO) { + withContext(Dispatchers.IO) { val seriesId = item.data.seriesId return@withContext if (seriesId != null) { - datePlayedCache - .get(SeriesItemId(seriesId, item.id)) { - getLastPlayed(seriesId, item) - }.await() + datePlayedCache.get(SeriesItemId(seriesId, item.id)).await() } else { null } } - /** - * Remove the cached last date played for the given item's series - * - * Used for when a user plays this item - */ fun invalidate(item: BaseItem) { item.data.seriesId?.let { seriesId -> Timber.d("Invalidating %s", seriesId) @@ -116,9 +86,6 @@ class DatePlayedService } } - /** - * Remove the cached last data played for the given item or its series - */ suspend fun invalidate(itemId: UUID) { val seriesId = api.userLibraryApi.getItem(itemId = itemId).content.let { @@ -140,9 +107,6 @@ class DatePlayedService } } -/** - * Invalidates the date played cached when the current user changes - */ @ActivityScoped class DatePlayedInvalidationService @Inject @@ -154,7 +118,7 @@ class DatePlayedInvalidationService private val activity = (context as AppCompatActivity) init { - serverRepository.current.collectLatestIn(activity.lifecycleScope) { + serverRepository.current.observe(activity) { datePlayedService.invalidateAll() } } 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 deleted file mode 100644 index 13ef5f70..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ /dev/null @@ -1,101 +0,0 @@ -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.ExperimentalPreferences -import com.github.damontecres.wholphin.preferences.HdrFormat -import com.github.damontecres.wholphin.preferences.HdrOverrideMode -import com.github.damontecres.wholphin.preferences.PlaybackOverrides -import com.github.damontecres.wholphin.preferences.enabled -import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor -import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion -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.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.model.ServerVersion -import org.jellyfin.sdk.model.api.DeviceProfile -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer - */ -@Singleton -class DeviceProfileService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - ) { - val mediaCodecCapabilitiesTest by lazy { - // Created lazily below on another thread since it cn take time - MediaCodecCapabilitiesTest(context) - } - private val mutex = Mutex() - - private var configuration: DeviceProfileConfiguration? = null - private var deviceProfile: DeviceProfile? = null - - suspend fun getOrCreateDeviceProfile( - appPrefs: AppPreferences, - serverVersion: ServerVersion?, - ): DeviceProfile = - withContext(WholphinDispatchers.Default) { - val prefs = appPrefs.playbackPreferences - mutex.withLock { - val newConfig = - DeviceProfileConfiguration( - maxBitrate = prefs.maxBitrate.toInt(), - overrides = prefs.overrides, - experimental = appPrefs.experimentalPreferences, - jellyfinTenEleven = - serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), - ) - if (deviceProfile == null || this@DeviceProfileService.configuration != newConfig) { - this@DeviceProfileService.configuration = newConfig - this@DeviceProfileService.deviceProfile = - createDeviceProfile( - mediaTest = mediaCodecCapabilitiesTest, - maxBitrate = newConfig.maxBitrate, - isAC3Enabled = newConfig.overrides.ac3Supported, - downMixAudio = newConfig.overrides.downmixStereo, - assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, - pgsDirectPlay = newConfig.overrides.directPlayPgs, - decodeAv1 = prefs.overrides.decodeAv1, - preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, - forceEnabledHdr = - buildSet { - appPrefs - .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven) - .let(::addAll) - // A profile 7 track the player converts to 8.1 is one the server need not transcode - if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) { - HdrFormat.DOVI_PROFILE_7 - .rangeTypeNames(newConfig.jellyfinTenEleven) - .let(::addAll) - } - }, - forceDisabledHdr = - appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), - jellyfinTenEleven = newConfig.jellyfinTenEleven, - maxResolution = newConfig.overrides.maxResolution, - ) - } - this@DeviceProfileService.deviceProfile!! - } - } - } - -/** - * The configuration used in [createDeviceProfile] - */ -data class DeviceProfileConfiguration( - val maxBitrate: Int, - val overrides: PlaybackOverrides, - val experimental: ExperimentalPreferences, - val jellyfinTenEleven: Boolean, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt deleted file mode 100644 index d5691fe6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import com.github.damontecres.wholphin.BuildConfig -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi -import org.jellyfin.sdk.model.UUID -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class DisplayPreferencesService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - ) { - private val mutex = Mutex() - - suspend fun getDisplayPreferences( - userId: UUID, - displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, - client: String = DEFAULT_CLIENT, - ) = api.displayPreferencesApi - .getDisplayPreferences( - userId = userId, - displayPreferencesId = displayPreferencesId, - client = client, - ).content - - suspend fun updateDisplayPreferences( - userId: UUID, - displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, - client: String = DEFAULT_CLIENT, - block: MutableMap<String, String?>.() -> Unit, - ) { - mutex.withLock { - val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID) - val customPrefs = - current.customPrefs.toMutableMap().apply { - block.invoke(this) - } - api.displayPreferencesApi.updateDisplayPreferences( - displayPreferencesId = displayPreferencesId, - userId = userId, - client = client, - data = current.copy(customPrefs = customPrefs), - ) - } - } - - companion object { - const val DEFAULT_DISPLAY_PREF_ID = "default" - val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin" - } - } 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 80a4e5ab..e952177b 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 @@ -1,37 +1,20 @@ package com.github.damontecres.wholphin.services -import android.content.Context -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 import org.jellyfin.sdk.model.api.ExtraType -import org.jellyfin.sdk.model.api.ImageType import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -/** - * Get extras for media - * - * @see [ExtrasItem] - */ @Singleton class ExtrasService @Inject constructor( private val api: ApiClient, - @param:ApplicationContext private val context: Context, - private val imageUrlService: ImageUrlService, ) { - /** - * Get the [ExtrasItem]s for the given item - */ suspend fun getExtras(itemId: UUID): List<ExtrasItem> { val extrasMap = api.userLibraryApi @@ -48,47 +31,9 @@ class ExtrasService extrasMap .mapNotNull { (type, items) -> if (items.size == 1) { - val item = items.first() - val title = - item.title.takeIf { it.isNotNullOrBlank() } - ?: context.resources.getQuantityString(type.pluralRes, 1) - val subtitle = - if (item.title.isNotNullOrBlank()) { - context.resources.getQuantityString(type.pluralRes, 1) - } else { - null - } - ExtrasItem.Single( - parentId = itemId, - type = type, - item = item, - title = title, - subtitle = subtitle, - imageUrl = imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), - ) + ExtrasItem.Single(itemId, type, items.first()) } else if (items.size > 1) { - 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, - items = items, - title = title, - subtitle = subtitle, - isPlayed = items.all { it.played }, - imageUrl = - imageUrlService.getItemImageUrl( - itemForImage, - ImageType.PRIMARY, - ), - aspectRatio = itemForImage.aspectRatio, - ) + ExtrasItem.Group(itemId, type, items) } else { null } @@ -97,9 +42,6 @@ class ExtrasService } } -/** - * The order which extras should be shown - */ private val ExtraType.sortOrder: Int get() = when (this) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index 93b3ebe4..a68a6fe5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.model.BaseItem import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -9,9 +8,6 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -/** - * Handles toggling media as favorited or watched - */ @Singleton class FavoriteWatchManager @Inject @@ -40,7 +36,4 @@ class FavoriteWatchManager } else { api.userLibraryApi.unmarkFavoriteItem(itemId).content } - - suspend fun removeContinueWatching(item: BaseItem) { - } } 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 deleted file mode 100644 index 52734f20..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FilterOptionCache.kt +++ /dev/null @@ -1,270 +0,0 @@ -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/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt deleted file mode 100644 index fe462b98..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ /dev/null @@ -1,765 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.core.net.toUri -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 -import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.data.model.createGenreDestination -import com.github.damontecres.wholphin.data.model.createStudioDestination -import com.github.damontecres.wholphin.preferences.HomePagePreferences -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -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.playback.getTypeFor -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.ui.util.ResArgStringProvider -import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider -import com.github.damontecres.wholphin.ui.util.ResStringProvider -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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.decodeFromStream -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -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.userLibraryApi -import org.jellyfin.sdk.model.DateTime -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.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.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.GetRecordingsRequest -import org.jellyfin.sdk.model.api.request.GetStudiosRequest -import timber.log.Timber -import java.time.LocalDateTime -import javax.inject.Inject -import javax.inject.Singleton - -@OptIn(ExperimentalSerializationApi::class) -@Singleton -class HomeDataService - @Inject - constructor( - private val api: ApiClient, - @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, - private val serverRepository: ServerRepository, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - private val suggestionService: SuggestionService, - ) { - /** - * Fetch the data from the server for a given [HomeRowConfig] - */ - suspend fun fetchDataForRow( - row: HomeRowConfig, - scope: CoroutineScope, - prefs: HomePagePreferences, - userDto: ServerUserConfig, - libraries: List<Library>, - limit: Int = prefs.maxItemsPerRow, - isRefresh: Boolean, - usePaging: Boolean = false, - ): HomeRowLoadingState = - when (row) { - is HomeRowConfig.ContinueWatching -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = resume, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = resume.size >= limit, - ) - } - - is HomeRowConfig.NextUp -> { - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.next_up), - items = nextUp, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = nextUp.size >= limit, - ) - } - - is HomeRowConfig.ContinueWatchingCombined -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - val combined = latestNextUpService.buildCombined(resume, nextUp) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = combined.take(limit), - viewOptions = row.viewOptions, - rowType = row, - showViewMore = combined.size >= limit, - ) - } - - is HomeRowConfig.Genres -> { - val request = - GetGenresRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - ) - val items = - GetGenresRequestHandler - .execute(api, request) - .content.items - val genreIds = items.map { it.id } - val genreImages = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser?.id, - scope = scope, - imageUrlService = imageUrlService, - genres = genreIds, - parentId = row.parentId, - includeItemTypes = null, - cardWidthPx = null, - useCache = isRefresh, - ) - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - - val title = - library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } - ?: ResStringProvider(R.string.genres) - val genres = - items.map { - BaseItem( - it, - false, - genreImages[it.id], - createGenreDestination( - genreId = it.id, - genreName = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = library?.includeItemTypes, - collectionType = - library?.collectionType - ?: CollectionType.UNKNOWN, - ), - ) - } - - Success( - title, - genres, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = genres.size >= limit, - ) - } - - is HomeRowConfig.Studios -> { - val request = - GetStudiosRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - includeItemTypes = listOf(BaseItemKind.SERIES), - ) - val items = - GetStudiosRequestHandler - .execute(api, request) - .content.items - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } - ?: ResStringProvider(R.string.studios) - val studios = - items.map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - tag = it.imageTags?.get(ImageType.THUMB), - ) - BaseItem( - it, - false, - imageUrl, - createStudioDestination( - studioId = it.id, - name = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = library?.includeItemTypes, - ), - ) - } - - Success( - title, - studios, - rowType = row, - viewOptions = row.viewOptions, - showViewMore = studios.size >= limit, - ) - } - - is HomeRowConfig.RecentlyAdded -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(library?.name) - val request = - GetLatestMediaRequest( - fields = library.itemFields, - imageTypeLimit = 1, - parentId = row.parentId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - latest - } - - is HomeRowConfig.RecentlyReleased -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { - ResArgStringProvider(R.string.recently_released_in, it) - } ?: ResStringProvider(R.string.recently_released) - val request = - GetItemsRequest( - parentId = row.parentId, - limit = limit, - sortBy = - listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SERIES_SORT_NAME, - ItemSortBy.AIRED_EPISODE_ORDER, - ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - SortOrder.DESCENDING, - ), - fields = library.itemFields, - recursive = true, - maxPremiereDate = LocalDateTime.now(), - isUnaired = false, - ) - 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, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.ByParent -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val request = - GetItemsRequest( - userId = userDto.id, - parentId = row.parentId, - recursive = row.recursive, - sortBy = - row.sort?.let { - buildList { - 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 { - 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 = library.itemFields, - ) - - // Not using getItemName because we want to throw the 404 - val title = - api.userLibraryApi - .getItem( - userId = serverRepository.currentUser?.id, - itemId = row.parentId, - ).content.name - ?.let { StringStringProvider(it) } - ?: ResStringProvider(R.string.collection) - 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, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.GetItems -> { - val request = - row.filter.asGetItemsRequest().copy( - userId = userDto.id, - limit = limit, - fields = HomeItemFieldsBoxSets, - ) - 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( - StringStringProvider(row.name), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Favorite -> { - val title = - ResProviderStringProvider( - R.string.favorite_items_title, - ResStringProvider(formatTypeName(row.kind)), - ) - 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) - } - } - } - resultList.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Recordings -> { - val request = - GetRecordingsRequest( - userId = userDto.id, - isInProgress = true, - fields = HomeItemFields, - limit = limit, - enableImages = true, - enableUserData = true, - enableTotalRecordCount = false, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetRecordingsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getRecordings(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - ResStringProvider(R.string.active_recordings), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.TvPrograms -> { - val request = - GetProgramsDto( - userId = userDto.id, - fields = ProgramItemFields, - limit = limit, - enableUserData = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), - imageTypeLimit = 1, - isAiring = true, - minEndDate = DateTime.now().plusMinutes(1), - ) - 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 = HomeItemFields, - limit = limit, - enableImages = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetLiveTvChannelsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getLiveTvChannels(request) - .toBaseItems(api, row.viewOptions.useSeries) - }.let { - Success( - ResStringProvider(R.string.channels), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Suggestions -> { - val library = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content - val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - if (itemKind != null) { - val suggestions = - suggestionService - .getSuggestionsFlow(row.parentId, itemKind) - .firstOrNull() - when (suggestions) { - SuggestionsResource.Empty -> { - Success( - title, - listOf(), - row.viewOptions, - rowType = row, - ) - } - - is SuggestionsResource.Success -> { - Success( - title, - suggestions.items, - row.viewOptions, - rowType = row, - showViewMore = suggestions.items.size >= limit, - ) - } - - SuggestionsResource.Loading, - null, - -> { - HomeRowLoadingState.Loading(title) - } - } - } else { - HomeRowLoadingState.Error( - title = title, - message = "Unsupported type ${library.collectionType}", - ) - } - } - - is HomeRowConfig.CustomEndpoint -> { - val title = StringStringProvider(row.title) - try { - val items = - fetchCustomEndpointItems(row) - .map { BaseItem(it, row.viewOptions.useSeries) } - Success(title, items, row.viewOptions, rowType = row) - } catch (ex: Exception) { - Timber.w(ex, "Custom endpoint %s failed", row.endpoint) - HomeRowLoadingState.Error(title, exception = ex) - } - } - } - - private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List<BaseItemDto> { - val base = - api.baseUrl?.toHttpUrlOrNull() - ?: throw IllegalStateException("Jellyfin baseUrl not set") - val endpointUri = row.endpoint.toUri() - if (!endpointUri.isRelative) { - throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}") - } - val params = - buildMap { - serverRepository.currentUser - ?.id - ?.toString() - ?.let { put("userId", it) } - row.query?.forEach { put(it.key, it.value) } - } - val resolved = - base - .resolve(row.endpoint) - ?.newBuilder() - ?.apply { - params.forEach { (k, v) -> addQueryParameter(k, v) } - }?.build() - ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}") - val request = - Request - .Builder() - .url(resolved) - .get() - .apply { - row.headers?.forEach { header(it.key, it.value) } - }.build() - val response = - authOkHttpClient - .newCall(request) - .execute() - return response.use { - if (!it.isSuccessful) { - throw InvalidStatusException(it.code, null) - } - val queryResult = - HomeSettingsService.jsonParser.decodeFromStream<BaseItemDtoQueryResult>(it.body.byteStream()) - Timber.v("Got %s items from custom endpoint", queryResult.items.size) - queryResult.items - } - } - } - -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 Library.includeItemTypes: List<BaseItemKind>? - get() = - collectionType.let { collectionType -> - getTypeFor(collectionType)?.let { type -> listOf(type) } - } - -private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) 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 deleted file mode 100644 index 1c05640e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ /dev/null @@ -1,716 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.JellyfinServerDao -import com.github.damontecres.wholphin.data.ServerRepository -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.JellyfinUser -import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.formatTypeName -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.util.ResArgStringProvider -import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider -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 dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToStream -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.cancellation.CancellationException - -/** - * Handles getting home page settings and data - */ -@Singleton -class HomeSettingsService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val navDrawerService: NavDrawerService, - private val displayPreferencesService: DisplayPreferencesService, - private val serverPluginApi: ServerPluginApi, - private val serverDao: JellyfinServerDao, - ) { - /** - * The current home page settings - */ - val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) - - /** - * Saves a [HomePageSettings] to the server for the user under the display preference ID - * - * @see loadFromServer - */ - suspend fun saveToServer( - userId: UUID, - settings: HomePageSettings, - displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, - ) { - displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) { - put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) - } - } - - /** - * Reads a [HomePageSettings] from the server for the user and display preference ID - * - * Returns null if there is none saved - * - * @see saveToServer - */ - suspend fun loadFromServer( - userId: UUID, - displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, - ): HomePageSettings? = - displayPreferencesService - .getDisplayPreferences(userId, displayPreferencesId) - .customPrefs[CUSTOM_PREF_ID] - ?.let { - val jsonElement = jsonParser.parseToJsonElement(it) - decode(jsonElement) - } - - /** - * Computes the filename for locally saved [HomePageSettings] - */ - private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json" - - /** - * Save the [HomePageSettings] for the user locally on the device - * - * @see loadFromLocal - */ - @OptIn(ExperimentalSerializationApi::class) - suspend fun saveToLocal( - userId: UUID, - settings: HomePageSettings, - ) { - val dir = File(context.filesDir, CUSTOM_PREF_ID) - dir.mkdirs() - File(dir, filename(userId)).outputStream().use { - jsonParser.encodeToStream(settings, it) - } - } - - /** - * Reads [HomePageSettings] for the user if it exists - * - * @see saveToLocal - */ - @OptIn(ExperimentalSerializationApi::class) - suspend fun loadFromLocal(userId: UUID): HomePageSettings? { - val dir = File(context.filesDir, CUSTOM_PREF_ID) - val file = File(dir, filename(userId)) - return if (file.exists()) { - val fileContents = file.readText() - val jsonElement = jsonParser.parseToJsonElement(fileContents) - decode(jsonElement) - } else { - null - } - } - - /** - * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows - */ - @VisibleForTesting - fun decode(element: JsonElement): HomePageSettings { - val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull - if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { - throw UnsupportedHomeSettingsVersionException(version) - } - val rowsElement = element.jsonObject["rows"]?.jsonArray - val rows = - rowsElement - ?.mapNotNull { row -> - try { - jsonParser.decodeFromJsonElement<HomeRowConfig>(row) - } catch (ex: Exception) { - Timber.w(ex, "Unknown row %s", row) - // TODO maybe use placeholder instead of null? - null - } - }.orEmpty() - return HomePageSettings(rows, version) - } - - private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = - try { - block.invoke() - } catch (ex: Exception) { - Timber.w(ex, "Error loading settings") - null - } - - suspend fun fetchSettingsFor( - userId: UUID, - source: HomePageSettingsSource, - ): HomePageResolvedSettings? { - val (resolvedSource, settings) = - when (source) { - HomePageSettingsSource.UNSET -> loadInOrder(userId) - HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId) - HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId) - HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings() - HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings() - HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings() - } - Timber.i( - "fetch home settings: resolvedSource=%s, userId=%s, found=%s", - resolvedSource, - userId, - settings != null, - ) - return settings?.let { - val resolvedRows = - settings.rows.mapIndexed { index, config -> - resolve(index, config) - } - HomePageResolvedSettings(userId, resolvedSource, resolvedRows) - } - } - - /** - * Loads [HomePageSettings] into [currentSettings] - * - * First checks locally, then on the server, and finally creates a default if needed - * - * Does not persist either the server nor default - */ - suspend fun loadCurrentSettings(user: JellyfinUser) { - currentSettings.update { HomePageResolvedSettings.EMPTY } - - Timber.v("Getting setting for %s", user.id) - val settings = - try { - fetchSettingsFor(user.id, user.config.homeSettingsSource) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) - null - } - Timber.v("Found settings: %s", settings != null) - val resolvedSettings = settings ?: createDefault(user.id) - currentSettings.update { resolvedSettings } - } - - /** - * Tries to load settings from local->server->plugin - */ - private suspend fun loadInOrder(userId: UUID): Pair<HomePageSettingsSource, HomePageSettings?> { - Timber.v("loadInOrder: local") - var settings = - HomePageSettingsSource.LOCAL to - tryLoad { - loadFromLocal(userId) - } - - if (settings.second == null) { - Timber.v("loadInOrder: server") - settings = - HomePageSettingsSource.SERVER_PROFILE to - tryLoad { - loadFromServer(userId) - } - } - - if (settings.second == null && serverRepository.serverPluginInstalled.value) { - Timber.v("loadInOrder: plugin") - settings = HomePageSettingsSource.PLUGIN to - tryLoad { - serverPluginApi.fetchHomePageSettings() - } - } - return settings - } - - /** - * Resolve the settings and set them to be the current settings - */ - suspend fun updateCurrent( - source: HomePageSettingsSource, - settings: HomePageSettings, - ) { - val resolvedRows = - settings.rows.mapIndexed { index, config -> - resolve(index, config) - } - currentSettings.update { it.copy(rows = resolvedRows) } - serverRepository.currentUser?.let { user -> - val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } - serverDao.updateUser(toSave) - } - } - - /** - * Create a default [HomePageResolvedSettings] using the available libraries - */ - suspend fun createDefault(userId: UUID): HomePageResolvedSettings { - Timber.v("Creating default settings") - val user = serverRepository.currentUser?.takeIf { it.id == userId } - val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId } - val libraries = - if (user != null) { - navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) - } else { - navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) - } - - val includedIds = - libraries - .mapIndexed { index, library -> - val parentId = library.itemId - val title = getRecentlyAddedTitle(library.name) - if (library.collectionType == CollectionType.LIVETV) { - HomeRowConfigDisplay( - id = index, - title = ResStringProvider(R.string.watch_live), - config = HomeRowConfig.TvPrograms(), - ) - } else { - val viewOptions = viewOptionsForCollectionType(library.collectionType) - HomeRowConfigDisplay( - id = index, - title = title, - config = - HomeRowConfig.RecentlyAdded( - parentId = parentId, - viewOptions = viewOptions, - ), - ) - } - } - val continueWatchingRow = - listOf( - HomeRowConfigDisplay( - id = includedIds.size + 1, - title = ResStringProvider(R.string.combine_continue_next), - config = HomeRowConfig.ContinueWatchingCombined(), - ), - ) - val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig) - } - - /** - * Create home page settings from the user's web UI home page settings - */ - suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { - val customPrefs = - displayPreferencesService - .getDisplayPreferences( - displayPreferencesId = "usersettings", - userId = userId, - client = "emby", - ).customPrefs - val userDto by api.userApi.getUserById(userId) - val config = userDto.configuration ?: DefaultUserConfiguration - val libraries = - navDrawerService - .getAllUserLibraries(userId, userDto.tvAccess) - .filterNot { - it.itemId in config.latestItemsExcludes - } - - return if (customPrefs.isNotEmpty()) { - var id = 0 - val rowConfigs = - (0..9) - .mapNotNull { idx -> - val sectionType = - HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase()) - Timber.v( - "sectionType=$sectionType, %s", - customPrefs["homesection$idx"]?.lowercase(), - ) - val config = - when (sectionType) { - HomeSectionType.ACTIVE_RECORDINGS -> { - HomeRowConfigDisplay( - id = id++, - title = ResStringProvider(R.string.active_recordings), - config = HomeRowConfig.Recordings(), - ) - } - - HomeSectionType.RESUME -> { - HomeRowConfigDisplay( - id = id++, - title = ResStringProvider(R.string.continue_watching), - config = HomeRowConfig.ContinueWatching(), - ) - } - - HomeSectionType.NEXT_UP -> { - HomeRowConfigDisplay( - id = id++, - title = ResStringProvider(R.string.next_up), - config = HomeRowConfig.NextUp(), - ) - } - - HomeSectionType.LIVE_TV -> { - if (userDto.tvAccess) { - HomeRowConfigDisplay( - id = id++, - title = ResStringProvider(R.string.watch_live), - config = HomeRowConfig.TvPrograms(), - ) - } else { - null - } - } - - HomeSectionType.LATEST_MEDIA -> { - // Handled below - null - } - - // Unsupported - HomeSectionType.RESUME_AUDIO, - HomeSectionType.RESUME_BOOK, - -> { - null - } - - HomeSectionType.SMALL_LIBRARY_TILES, - HomeSectionType.LIBRARY_BUTTONS, - HomeSectionType.NONE, - null, - -> { - null - } - } - if (sectionType == HomeSectionType.LATEST_MEDIA) { - libraries.map { library -> - val viewOptions = - viewOptionsForCollectionType(library.collectionType) - HomeRowConfigDisplay( - id = id++, - title = - ResArgStringProvider( - R.string.recently_added_in, - library.name ?: "", - ), - config = - HomeRowConfig.RecentlyAdded( - parentId = library.itemId, - viewOptions = viewOptions, - ), - ) - } - } else if (config != null) { - listOf(config) - } else { - null - } - }.flatten() - HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) - } else { - null - } - } - - /** - * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes - */ - suspend fun resolve( - id: Int, - config: HomeRowConfig, - ): HomeRowConfigDisplay = - when (config) { - is HomeRowConfig.ByParent -> { - val name = getItemName(null, config.parentId) - HomeRowConfigDisplay( - id, - name, - config, - ) - } - - is HomeRowConfig.ContinueWatching -> { - HomeRowConfigDisplay( - id, - ResStringProvider(R.string.continue_watching), - config, - ) - } - - is HomeRowConfig.ContinueWatchingCombined -> { - HomeRowConfigDisplay( - id, - ResStringProvider(R.string.combine_continue_next), - config, - ) - } - - is HomeRowConfig.Genres -> { - val title = getItemName(R.string.genres_in, config.parentId) - HomeRowConfigDisplay( - id, - title, - config, - ) - } - - is HomeRowConfig.Studios -> { - val title = getItemName(R.string.studios_in, config.parentId) - HomeRowConfigDisplay( - id, - title, - config, - ) - } - - is HomeRowConfig.GetItems -> { - HomeRowConfigDisplay(id, StringStringProvider(config.name), config) - } - - is HomeRowConfig.NextUp -> { - HomeRowConfigDisplay( - id, - ResStringProvider(R.string.next_up), - config, - ) - } - - is HomeRowConfig.RecentlyAdded -> { - val title = getItemName(R.string.recently_added_in, config.parentId) - HomeRowConfigDisplay( - id, - title, - config, - ) - } - - is HomeRowConfig.RecentlyReleased -> { - val title = getItemName(R.string.recently_released_in, config.parentId) - HomeRowConfigDisplay( - id, - title, - config, - ) - } - - is HomeRowConfig.Favorite -> { - val name = - ResProviderStringProvider( - R.string.favorite_items_title, - ResStringProvider(formatTypeName(config.kind)), - ) - HomeRowConfigDisplay(id, name, config) - } - - is HomeRowConfig.Recordings -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.active_recordings), - config, - ) - } - - is HomeRowConfig.TvPrograms -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.watch_live), - config, - ) - } - - is HomeRowConfig.TvChannels -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.channels), - config, - ) - } - - is HomeRowConfig.Suggestions -> { - val title = getItemName(R.string.suggestions_for, config.parentId) - HomeRowConfigDisplay( - id = id, - title = title, - config, - ) - } - - is HomeRowConfig.CustomEndpoint -> { - HomeRowConfigDisplay( - id = id, - title = StringStringProvider(config.title), - config, - ) - } - } - - private suspend fun getItemName( - @StringRes stringRes: Int?, - itemId: UUID, - default: StringProvider = StringStringProvider(""), - ): StringProvider = - try { - api.userLibraryApi - .getItem( - userId = serverRepository.currentUser?.id, - itemId = itemId, - ).content.name - ?.let { - if (stringRes == null) { - StringStringProvider(it) - } else { - ResArgStringProvider(stringRes, it) - } - } ?: default - } catch (ex: Exception) { - Timber.e(ex, "Could not get name for %s", itemId) - ResStringProvider(R.string.unknown) - } - - companion object { - const val CUSTOM_PREF_ID = "home_settings" - - @OptIn(ExperimentalSerializationApi::class) - val jsonParser = - Json { - isLenient = true - ignoreUnknownKeys = true - allowTrailingComma = true - } - } - } - -/** - * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI - */ -data class HomeRowConfigDisplay( - val id: Int, - val title: StringProvider, - val config: HomeRowConfig, -) - -/** - * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s - * - * @see HomePageSettings - */ -data class HomePageResolvedSettings( - val userId: UUID, - val source: HomePageSettingsSource, - val rows: List<HomeRowConfigDisplay>, -) { - fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) - - companion object { - val EMPTY = - HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList()) - } -} - -// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs -enum class HomeSectionType( - val serialName: String, -) { - NONE("none"), - SMALL_LIBRARY_TILES("smalllibrarytitles"), - LIBRARY_BUTTONS("librarybuttons"), - ACTIVE_RECORDINGS("activerecordings"), - RESUME("resume"), - RESUME_AUDIO("resumeaudio"), - LATEST_MEDIA("latestmedia"), - NEXT_UP("nextup"), - LIVE_TV("livetv"), - RESUME_BOOK("resumebook"), - ; - - companion object { - fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } } - } -} - -class UnsupportedHomeSettingsVersionException( - val unsupportedVersion: Int?, - val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, -) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") - -fun getRecentlyAddedTitle(name: String?): StringProvider = - name?.let { ResArgStringProvider(R.string.recently_added_in, it) } - ?: ResStringProvider(R.string.recently_added) - -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() - } - } - -enum class HomePageSettingsSource( - @param:StringRes val stringResId: Int, -) { - UNSET(R.string.unknown), - LOCAL(R.string.home_settings_source_local), - SERVER_PROFILE(R.string.home_settings_source_server_profile), - PLUGIN(R.string.home_settings_source_plugin), - DEFAULT(R.string.home_settings_source_unset), - WEB_CONFIG(R.string.home_settings_source_web), -} 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 deleted file mode 100644 index 967629da..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ /dev/null @@ -1,286 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.github.damontecres.wholphin.data.model.BaseItem -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageFormat -import org.jellyfin.sdk.model.api.ImageType -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Provides image URLs for items with several convenience methods - * - * This is available in compose UI code via [com.github.damontecres.wholphin.ui.LocalImageUrlService] - */ -@Singleton -class ImageUrlService - @Inject - constructor( - private val api: ApiClient, - ) { - fun getItemImageUrl( - itemId: UUID, - itemType: BaseItemKind, - seriesId: UUID?, - useSeriesForPrimary: Boolean, - imageType: ImageType, - imageTags: Map<ImageType, String?>, - backdropTags: List<String>, - parentThumbId: UUID? = null, - parentBackdropId: UUID? = null, - fillWidth: Int? = null, - fillHeight: Int? = null, - seriesPrimaryTag: String? = null, - parentLogoId: UUID? = null, - parentLogoTag: String? = null, - parentThumbTag: String? = null, - parentBackdropTags: List<String> = emptyList(), - ): String? = - when (imageType) { - ImageType.LOGO -> { - if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { - getItemImageUrl( - itemId = seriesId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = parentLogoTag.takeIf { parentLogoId == seriesId }, - ) - } else { - getItemImageUrl( - itemId = itemId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = imageTags[imageType], - ) - } - } - - ImageType.BACKDROP, - -> { - if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { - getItemImageUrl( - itemId = seriesId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, - ) - } else if (backdropTags.isNotEmpty()) { - getItemImageUrl( - itemId = itemId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = backdropTags.first(), - ) - } else { - null - } - } - - ImageType.THUMB -> { - if (useSeriesForPrimary && parentThumbId != null && - (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) - ) { - // Use parent's thumb - getItemImageUrl( - itemId = parentThumbId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = parentThumbTag, - ) - } else if (useSeriesForPrimary && parentBackdropId != null && - (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) - ) { - // No parent thumb, so use backdrop instead - getItemImageUrl( - itemId = parentBackdropId, - imageType = ImageType.BACKDROP, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = parentBackdropTags.firstOrNull(), - ) - } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { - getItemImageUrl( - itemId = parentThumbId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = parentThumbTag, - ) - } else if (useSeriesForPrimary && - parentThumbId == null && - itemType == BaseItemKind.EPISODE && - imageType !in imageTags - ) { - // Workaround to fall back to episode image if no parent thumb - getItemImageUrl( - itemId = itemId, - imageType = ImageType.PRIMARY, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = imageTags[ImageType.PRIMARY], - ) - } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { - // If no thumb, use backdrop if available - getItemImageUrl( - itemId = itemId, - imageType = ImageType.BACKDROP, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = backdropTags.first(), - ) - } else { - getItemImageUrl( - itemId = itemId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = imageTags[imageType], - ) - } - } - - ImageType.PRIMARY, - ImageType.BANNER, - -> { - if (useSeriesForPrimary && seriesId != null && - (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) - ) { - getItemImageUrl( - itemId = seriesId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, - ) - } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { - getItemImageUrl( - itemId = seriesId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, - ) - } else { - getItemImageUrl( - itemId = itemId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = imageTags[imageType], - ) - } - } - - else -> { - getItemImageUrl( - itemId = itemId, - imageType = imageType, - fillWidth = fillWidth, - fillHeight = fillHeight, - tag = imageTags[imageType], - ) - } - } - - fun getItemImageUrl( - item: BaseItem?, - imageType: ImageType, - fillWidth: Int? = null, - fillHeight: Int? = null, - useSeriesForPrimary: Boolean? = null, - ): String? = - if (item != null) { - getItemImageUrl( - itemId = item.id, - itemType = item.type, - seriesId = item.data.seriesId, - useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary, - imageTags = item.data.imageTags.orEmpty(), - imageType = imageType, - parentThumbId = item.data.parentThumbItemId, - parentBackdropId = item.data.parentBackdropItemId, - backdropTags = item.data.backdropImageTags.orEmpty(), - fillWidth = fillWidth, - fillHeight = fillHeight, - seriesPrimaryTag = item.data.seriesPrimaryImageTag, - parentLogoId = item.data.parentLogoItemId, - parentLogoTag = item.data.parentLogoImageTag, - parentThumbTag = item.data.parentThumbImageTag, - parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(), - ) - } else { - null - } - - fun getItemImageUrl( - itemId: UUID, - imageType: ImageType, - maxWidth: Int? = null, - maxHeight: Int? = null, - width: Int? = null, - height: Int? = null, - quality: Int? = QUALITY, - fillWidth: Int? = null, - fillHeight: Int? = null, - tag: String? = null, - format: ImageFormat? = null, - percentPlayed: Double? = null, - unplayedCount: Int? = null, - blur: Int? = null, - backgroundColor: String? = null, - foregroundLayer: String? = null, - imageIndex: Int? = null, - ): String? { - if (api.baseUrl.isNullOrBlank()) return null - return api.imageApi.getItemImageUrl( - itemId = itemId, - imageType = imageType, - maxWidth = maxWidth, - maxHeight = maxHeight, - width = width?.takeIf { it > 0 }, - height = height?.takeIf { it > 0 }, - quality = quality, - fillWidth = fillWidth?.takeIf { it > 0 }, - fillHeight = fillHeight?.takeIf { it > 0 }, - tag = tag, - format = format, - percentPlayed = percentPlayed, - unplayedCount = unplayedCount, - blur = blur, - backgroundColor = backgroundColor, - foregroundLayer = foregroundLayer, - imageIndex = imageIndex, - ) - } - - fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId) - - /** - * Just a convenient way to get the image URL and remember it - */ - @Composable - fun rememberImageUrl( - item: BaseItem?, - imageType: ImageType = ImageType.PRIMARY, - ) = remember(item, imageType) { - if (item != null) { - getItemImageUrl(item, imageType) - } else { - null - } - } - - companion object { - private const val QUALITY = 96 - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt deleted file mode 100644 index 4fda8260..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ /dev/null @@ -1,218 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.app.SearchManager -import android.content.Intent -import androidx.annotation.VisibleForTesting -import com.github.damontecres.wholphin.data.RestoredSession -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) - } - - @VisibleForTesting - 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.tryChangeUser(serverId, userId) - if (user != null) { - null - } else { - IntentResult.Error("Error restoring user") - } - } else if (appPrefs.signInAutomatically) { - Timber.v("No current user, so restoring last") - val result = serverRepository.restoreLastSession() - Timber.v("Restored result=%s", result) - when (result) { - RestoredSession.None, - is RestoredSession.ServerOnly, - -> { - IntentResult.Error("Could not auto sign-in, specify a server & user") - } - - is RestoredSession.Success -> { - if (result.currentUser.user.isProtected) { - IntentResult.Error("Could not auto sign-in, user is protected") - } else { - null - } - } - } - } else { - IntentResult.Error("Auto sign-in not enabled") - } - } - - 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/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt deleted file mode 100644 index 543ea656..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.stringPreferencesKey -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Gets/saves a serializable object by name - */ -@Singleton -class KeyValueService - @Inject - constructor( - val dataStore: DataStore<Preferences>, - ) { - val json = - Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = false - } - - inline fun <reified T> get(key: String): Flow<T?> = - dataStore.data.map { preferences -> - preferences[stringPreferencesKey(key)]?.let { - json.decodeFromString<T>(serializer<T>(), it) - } - } - - inline fun <reified T> get( - key: String, - defaultValue: T, - ): Flow<T> = - dataStore.data.map { preferences -> - preferences[stringPreferencesKey(key)]?.let { - json.decodeFromString<T>(serializer<T>(), it) - } ?: defaultValue - } - - inline fun <reified T> get( - userId: UUID, - key: String, - defaultValue: T, - ): Flow<T> = - dataStore.data.map { preferences -> - preferences[stringPreferencesKey("${userId}_$key")]?.let { - json.decodeFromString<T>(serializer<T>(), it) - } ?: defaultValue - } - - suspend inline fun <reified T> save( - key: String, - value: T, - ) { - dataStore.updateData { preferences -> - val valueStr = json.encodeToString(value) - preferences.toMutablePreferences().apply { - set(stringPreferencesKey(key), valueStr) - } - } - } - - suspend inline fun <reified T> save( - userId: UUID, - key: String, - value: T, - ) { - dataStore.updateData { preferences -> - val valueStr = json.encodeToString(value) - preferences.toMutablePreferences().apply { - set(stringPreferencesKey("${userId}_$key"), valueStr) - } - } - } - } 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 deleted file mode 100644 index dbaa219e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ /dev/null @@ -1,274 +0,0 @@ -@file:UseSerializers( - UUIDSerializer::class, - LocalDateTimeSerializer::class, -) - -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.HomeSlimItemFields -import com.github.damontecres.wholphin.util.LocalDateTimeSerializer -import com.github.damontecres.wholphin.util.WholphinDispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.api.client.ApiClient -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 -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import timber.log.Timber -import java.time.LocalDateTime -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.milliseconds - -/** - * Get continue watching and next up items for users - */ -@Singleton -class LatestNextUpService - @Inject - constructor( - private val api: ApiClient, - private val datePlayedService: DatePlayedService, - private val displayPreferencesService: DisplayPreferencesService, - private val favoriteWatchManager: FavoriteWatchManager, - ) { - /** - * Get resume (continue watching) items for a user - */ - suspend fun getResume( - userId: UUID, - limit: Int, - includeEpisodes: Boolean, - useSeriesForPrimary: Boolean = true, - ): List<BaseItem> { - val request = - GetResumeItemsRequest( - userId = userId, - fields = HomeSlimItemFields, - limit = limit, - mediaTypes = listOf(MediaType.VIDEO), - excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, - enableTotalRecordCount = false, - ) - val items = - api.itemsApi - .getResumeItems(request) - .content - .items - .map { BaseItem(it, useSeriesForPrimary) } - return items - } - - /** - * Get next up items for a user - */ - suspend fun getNextUp( - userId: UUID, - limit: Int, - enableRewatching: Boolean, - enableResumable: Boolean, - maxDays: Int, - useSeriesForPrimary: Boolean = true, - ): List<BaseItem> { - val removedSeries = getRemovedFromNextUp(userId) - val nextUpDateCutoff = - maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } - val request = - GetNextUpRequest( - userId = userId, - fields = HomeSlimItemFields, - imageTypeLimit = 1, - parentId = null, - limit = limit, - enableResumable = enableResumable, - enableUserData = true, - enableRewatching = enableRewatching, - nextUpDateCutoff = nextUpDateCutoff, - enableTotalRecordCount = false, - ) - val nextUp = - api.tvShowsApi - .getNextUp(request) - .content - .items - .map { BaseItem(it, useSeriesForPrimary) } - .filter { - val seriesId = it.data.seriesId - if (seriesId != null && seriesId in removedSeries) { - // User has previously removed the series - val lastPlayedDate = it.data.userData?.lastPlayedDate - if (lastPlayedDate != null) { - // If item played it after it was removed, should include it - lastPlayedDate > removedSeries[seriesId] - } else { - // If unknown last played, filter out - false - } - } else { - true - } - } - - return nextUp - } - - /** - * Create the combined Continue Watching & Next Up items - * - * @see [DatePlayedService] - */ - suspend fun buildCombined( - resume: List<BaseItem>, - nextUp: List<BaseItem>, - ): List<BaseItem> = - withContext(WholphinDispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - async(WholphinDispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf<UUID, LocalDateTime?>() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } - - /** - * Remove a series from next up - */ - suspend fun removeFromNextUp( - userId: UUID, - episode: BaseItem, - ) { - favoriteWatchManager.setWatched(episode.id, false) - episode.data.seriesId?.let { seriesId -> - displayPreferencesService.updateDisplayPreferences(userId) { - val removedIds = - get(REMOVED_KEY) - ?.let { - Json.decodeFromString<RemovedSeriesIds>(it).value - }.orEmpty() - .toMutableMap() - removedIds[seriesId] = LocalDateTime.now() - put( - REMOVED_KEY, - Json.encodeToString(RemovedSeriesIds(removedIds)), - ) - } - } - } - - /** - * Get when series were removed from next up - */ - suspend fun getRemovedFromNextUp(userId: UUID): Map<UUID, LocalDateTime> = - displayPreferencesService - .getDisplayPreferences(userId) - .customPrefs[REMOVED_KEY] - ?.let { - Json.decodeFromString<RemovedSeriesIds>(it).value - }.orEmpty() - - suspend fun allowSeriesRemovedFromNextUp( - userId: UUID, - seriesId: UUID, - ) { - displayPreferencesService.updateDisplayPreferences(userId) { - val ids = - get(REMOVED_KEY) - ?.let { - Json.decodeFromString<RemovedSeriesIds>(it).value - }.orEmpty() - .toMutableMap() - ids.remove(seriesId) - put( - REMOVED_KEY, - Json.encodeToString(RemovedSeriesIds(ids)), - ) - } - } - - /** - * Check if user has watched a series since removing it - */ - suspend fun updateRemovedFromNextUp(userId: UUID) { - val removed = getRemovedFromNextUp(userId) - val newRemoved = removed.toMutableMap() - var changed = false - removed.forEach { (seriesId, timestamp) -> - val item = - api.itemsApi - .getItems( - userId = userId, - parentId = seriesId, - recursive = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - sortBy = listOf(ItemSortBy.DATE_PLAYED), - sortOrder = listOf(SortOrder.DESCENDING), - limit = 1, - ).content.items - .firstOrNull() - if (item != null) { - val lastPlayed = item.userData?.lastPlayedDate - if (lastPlayed != null && lastPlayed > timestamp) { - Timber.v("Updating removed next up for series %s", seriesId) - newRemoved.remove(seriesId) - changed = true - } - } else { - // Series doesn't exist anymore - Timber.v("Updating removed next up for missing series %s", seriesId) - newRemoved.remove(seriesId) - changed = true - } - } - if (changed) { - displayPreferencesService.updateDisplayPreferences(userId) { - put( - REMOVED_KEY, - Json.encodeToString(RemovedSeriesIds(newRemoved)), - ) - } - } - } - - companion object { - const val REMOVED_KEY = "removeNextUp" - } - } - -@Serializable -data class RemovedSeriesIds( - val value: Map<UUID, LocalDateTime>, -) 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 deleted file mode 100644 index a60bf7c4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AppCompatActivity -import androidx.hilt.work.HiltWorker -import androidx.lifecycle.lifecycleScope -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import com.github.damontecres.wholphin.data.RestoredSession -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.launch -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.toJavaDuration - -@HiltWorker -class LatestNextUpWorker - @AssistedInject - constructor( - @Assisted private val context: Context, - @Assisted workerParams: WorkerParameters, - private val serverRepository: ServerRepository, - private val api: ApiClient, - private val latestNextUpService: LatestNextUpService, - ) : CoroutineWorker(context, workerParams) { - override suspend fun doWork(): Result { - Timber.d("Start") - val serverId = - inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() - val userId = - inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() - - try { - if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - // Not active - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { - Timber.w("No user found during run") - return Result.failure() - } - } - latestNextUpService.updateRemovedFromNextUp(userId) - return Result.success() - } catch (ex: Exception) { - Timber.e(ex, "Error during updateRemovedFromNextUp") - return Result.retry() - } - } - - companion object { - const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker" - const val PARAM_USER_ID = "userId" - const val PARAM_SERVER_ID = "serverId" - } - } - -@ActivityScoped -class LatestNextUpSchedulerService - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val workManager: WorkManager, - ) { - private val activity = - (context as? AppCompatActivity) - ?: throw IllegalStateException( - "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", - ) - - @VisibleForTesting - internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO - - init { - serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> - Timber.v("New user %s", user?.user?.id) - if (user == null) { - workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) - } else { - activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { - scheduleWork(user.user.id, user.server.id) - } - } - } - } - - private suspend fun scheduleWork( - userId: UUID, - serverId: UUID, - ) { - val constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - val periodicWorkRequestBuilder = - PeriodicWorkRequestBuilder<LatestNextUpWorker>( - repeatInterval = 4.hours.toJavaDuration(), - ).setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - 15.minutes.toJavaDuration(), - ).setConstraints(constraints) - .setInputData( - workDataOf( - LatestNextUpWorker.PARAM_USER_ID to userId.toString(), - LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(), - ), - ) - Timber.i("Scheduling periodic LatestNextUpWorker") - - workManager.enqueueUniquePeriodicWork( - uniqueWorkName = LatestNextUpWorker.WORK_NAME, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE, - request = periodicWorkRequestBuilder.build(), - ) - } - } 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 deleted file mode 100644 index d89916f0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt +++ /dev/null @@ -1,94 +0,0 @@ -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/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt deleted file mode 100644 index 684d56b0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.combinePair -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.showToast -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.collectLatest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.libraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Service to manage media such as deletions - */ -@Singleton -class MediaManagementService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, - ) { - private val _deletedItemFlow = - MutableSharedFlow<DeletedItem>( - replay = 1, - extraBufferCapacity = 0, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - /** - * Listen for recently deleted items. Useful for ViewModels to react and refresh data - */ - val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow - - suspend fun canDelete(item: BaseItem): Boolean { - val appPreferences = userPreferencesService.getCurrent().appPreferences - return canDelete(item, appPreferences) - } - - /** - * Check if the item can be deleted. This means the app setting is enabled and the user has permission. - */ - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean { - Timber.v("canDelete %s: %s", item.id, item.canDelete) - val enabled = appPreferences.interfacePreferences.enableMediaManagement - return enabled && - item.canDelete && - if (item.type == BaseItemKind.RECORDING) { - serverRepository.currentUserDto - ?.policy - ?.enableLiveTvManagement == true - } else { - true - } - } - - suspend fun collectCanDelete( - itemFlow: Flow<BaseItem?>, - update: (Boolean) -> Unit, - ) { - itemFlow.combinePair(userPreferencesService.flow).collectLatest { (item, prefs) -> - val canDelete = item?.let { canDelete(item, prefs.appPreferences) } ?: false - update.invoke(canDelete) - } - } - - /** - * Delete the item. - * - * This item will be sent through [deletedItemFlow] for other services or view models to react. - */ - suspend fun deleteItem(item: BaseItem): DeleteResult { - try { - Timber.i("Deleting %s", item.id) - api.libraryApi.deleteItem(item.id) - _deletedItemFlow.emit(DeletedItem(item)) - return DeleteResult.Success - } catch (ex: Exception) { - Timber.e(ex, "Error deleting %s", item.id) - return DeleteResult.Error(ex) - } - } - } - -data class DeletedItem( - val item: BaseItem, -) - -sealed interface DeleteResult { - data object Success : DeleteResult - - data class Error( - val ex: Exception, - ) : DeleteResult -} - -/** - * Convenience function to delete an item and show a Toast based on success or error - */ -fun ViewModel.deleteItem( - context: Context, - mediaManagementService: MediaManagementService, - item: BaseItem, - onSuccess: () -> Unit = {}, -) = viewModelScope.launchIO { - when (val r = mediaManagementService.deleteItem(item)) { - is DeleteResult.Error -> { - showToast( - context, - "Error deleting item: ${r.ex.localizedMessage}", - ) - } - - DeleteResult.Success -> { - showToast(context, "Deleted") - onSuccess.invoke() - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt deleted file mode 100644 index 16ac4edd..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ /dev/null @@ -1,605 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.annotation.OptIn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.util.UnstableApi -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.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 -import com.github.damontecres.wholphin.ui.seekForward -import com.github.damontecres.wholphin.ui.toServerString -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.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -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 -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.instantMixApi -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 -import org.jellyfin.sdk.model.extensions.ticks -import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.seconds - -/** - * Manage the global state for playing music - * - * Has functions for modifying the queue - */ -@OptIn(UnstableApi::class) -@Singleton -class MusicService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - @param:DefaultCoroutineScope private val defaultScope: CoroutineScope, - private val api: ApiClient, - 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 - - private val audioFormats by lazy { listOf(*supportedAudioCodecs) } - - val player: Player by lazy { - val disableAudioOffload = - runBlocking { - userPreferencesService - .getCurrent() - .appPreferences.experimentalPreferences - .get { disableAudioOffload } ?: false - } - playerFactory - .createAudioPlayer(disableAudioOffload = disableAudioOffload) - .also { - it.addListener(MusicPlayerListener(it, _state)) - } - } - - private val mutex = Mutex() - var mediaSession: MediaSession? = null - private set - private var activityTracker: TrackActivityPlaybackListener? = null - private var websocketJob: Job? = null - - private suspend fun preferAc3Surround() = - userPreferencesService - .getCurrent() - .appPreferences.experimentalPreferences - .enabled { preferAc3Surround } - - /** - * Start music playback - * - * Sets up the media session, activity tracking, and actual playback - */ - suspend fun start() { - if (mediaSession == null) { - mutex.withLock { - if (mediaSession == null) { - Timber.i("Starting music MediaSession") - mediaSession = - MediaSession - .Builder(context, player) - .setCallback(TrustAllControllersCallback) - .build() - activityTracker = - TrackActivityPlaybackListener(api, player) { - state.value.currentItemId?.let { itemId -> - PlaybackItemState( - itemId = itemId, - playMethod = PlayMethod.DIRECT_PLAY, -// playSessionId = mediaSession?.id, - ) - } - }.also { player.addListener(it) } - websocketJob = subscribe() - } - } - } - onMain { - player.prepare() - player.play() - } - } - - /** - * Stop music playback - */ - suspend fun stop() { - mutex.withLock { - Timber.i("Stopping music") - if (mediaSession == null) { - Timber.w("Stopping but no MediaSession") - } - mediaSession?.release() - mediaSession = null - onMain { - websocketJob?.cancel() - websocketJob = null - activityTracker?.let { - it.release() - player.removeListener(it) - } - activityTracker = null - player.stop() - player.setMediaItems(emptyList()) - } - _state.update { - MusicServiceState.EMPTY - } - } - } - - /** - * Fetches instant mix items, replaces the queue, and begins playback - */ - suspend fun startInstantMix(itemId: UUID) = - loading { - val items = - api.instantMixApi - .getInstantMixFromItem( - userId = serverRepository.currentUser?.id, - itemId = itemId, - limit = 200, - fields = DetailItemFields, - ).content.items - .map { BaseItem(it, false) } - setQueue(items, false) - } - - /** - * Replace the queue with the given list and starting playing the song as startIndex as soon as its ready - * - * Fetches each item in a blocking way and adds to the queue - */ - suspend fun setQueue( - items: BlockingList<BaseItem?>, - startIndex: Int, - shuffled: Boolean, - ) = withContext(WholphinDispatchers.IO) { - Timber.d("setQueue: %s items, startIndex=%s, shuffled=%s", items.size, startIndex, shuffled) - withContext(WholphinDispatchers.Main) { - player.setMediaItems(emptyList()) - player.shuffleModeEnabled = shuffled - } - start() - addAllToQueue(items, startIndex) - } - - /** - * Replace the queue with the given items - */ - suspend fun setQueue( - items: List<BaseItem>, - 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(it, preferAc3Surround) } - withContext(WholphinDispatchers.Main) { - player.setMediaItems(mediaItems) - player.shuffleModeEnabled = shuffled - updateQueueSize() - start() - } - } - - /** - * Add an item to the specified index of the queue. If no index specified, it will be added to the end. - */ - suspend fun addToQueue( - item: BaseItem, - index: Int? = null, - ) { - if (item.type == BaseItemKind.AUDIO) { - val preferAc3Surround = preferAc3Surround() - val mediaItem = convert(item, preferAc3Surround) - withContext(WholphinDispatchers.Main) { - if (index != null) { - player.addMediaItem(index, mediaItem) - } else { - player.addMediaItem(mediaItem) - } - updateQueueSize() - if (player.mediaItemCount == 1) { - // Start playing if this was the first time added - start() - } - } - } else { - Timber.w("Tried to add non-audio type %s: %s", item.type, item.id) - } - } - - /** - * Add all the items in teh list to end of the queue - * - * @param startIndex The index to start from within the source list - */ - suspend fun addAllToQueue( - list: BlockingList<BaseItem?>, - startIndex: Int, - ) = loading { - val preferAc3Surround = preferAc3Surround() - var remaining = startIndex - list.indices - .chunked(25) - .forEach { - val mediaItems = - it.mapNotNull { - if (remaining == 0) { - list - .getBlocking(it) - ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let { convert(it, preferAc3Surround) } - } else { - Timber.v("Skipping $remaining") - remaining-- - null - } - } - onMain { player.addMediaItems(mediaItems) } - } - updateQueueSize() - start() - } - - /** - * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag - */ - 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 = - 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 -> - imageUrlService.getItemImageUrl( - itemId = albumId, - imageType = ImageType.PRIMARY, - tag = audio.data.albumPrimaryImageTag, - ) - } - return MediaItem - .Builder() - .setUri(url) - .setMediaId(audio.id.toServerString()) - .setTag(AudioItem.from(audio, imageUrl)) - .build() - } - - /** - * Updates the state for when the queue changes - */ - private suspend fun updateQueueSize() { -// val ids = -// withContext(WholphinDispatchers.Default) { -// (0..<player.mediaItemCount).map { player.getMediaItemAt(it).mediaId.toUUID() } -// } - val timeline = onMain { player.currentTimeline } - val window = Timeline.Window() - val ids = - (0..<timeline.windowCount) - .map { - timeline.getWindow(it, window) - window.mediaItem.mediaId.toUUID() - }.toSet() - withContext(WholphinDispatchers.Main) { - _state.update { - it.copy( - queueVersion = it.queueVersion + 1, - queueSize = player.mediaItemCount, - queuedIds = ids, - ) - } - } - } - - /** - * Move an item within the queue - */ - suspend fun moveQueue( - index: Int, - direction: MoveDirection, - ) = withContext(WholphinDispatchers.Main) { - player.moveMediaItem(index, if (direction == MoveDirection.UP) index - 1 else index + 1) - updateQueueSize() - } - - /** - * Move an item within the queue - */ - suspend fun moveQueue( - index: Int, - newIndex: Int, - ) = withContext(WholphinDispatchers.Main) { - player.moveMediaItem(index, newIndex) - updateQueueSize() - } - - /** - * Start playback at the given index of the queue - */ - suspend fun playIndex(index: Int) { - onMain { - player.seekTo(index, 0L) - player.play() - } - // MusicPlayerListener will update state - } - - /** - * 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, preferAc3Surround()) - onMain { - player.addMediaItem(state.value.currentIndex + 1, mediaItem) - if (player.mediaItemCount == 1) { - start() - } - } - updateQueueSize() - } - - /** - * From the item at the given index from the queue - */ - suspend fun removeFromQueue(index: Int) { - onMain { player.removeMediaItem(index) } - updateQueueSize() - } - - private suspend fun <T> loading(block: suspend () -> T): T { - _state.update { it.copy(loadingState = LoadingState.Loading) } - val result = block.invoke() - _state.update { it.copy(loadingState = LoadingState.Success) } - return result - } - - /** - * Subscribes to the server websocket to receive playback commands - */ - private fun subscribe(): Job = - api.webSocket - .subscribe<PlaystateMessage>() - .onEach { message -> - message.data?.let { - withContext(WholphinDispatchers.Main) { - when (it.command) { - PlaystateCommand.STOP -> { - stop() - } - - PlaystateCommand.PAUSE -> { - player.pause() - } - - PlaystateCommand.UNPAUSE -> { - player.play() - } - - PlaystateCommand.NEXT_TRACK -> { - player.seekToNext() - } - - PlaystateCommand.PREVIOUS_TRACK -> { - player.seekToPrevious() - } - - PlaystateCommand.SEEK -> { - it.seekPositionTicks?.ticks?.let { - player.seekTo( - it.inWholeMilliseconds, - ) - } - } - - PlaystateCommand.REWIND -> { - player.seekBack(10.seconds) - } - - PlaystateCommand.FAST_FORWARD -> { - player.seekForward(30.seconds) - } - - PlaystateCommand.PLAY_PAUSE -> { - if (player.isPlaying) player.pause() else player.play() - } - } - } - } - }.catch { ex -> - Timber.e(ex, "Error in websocket subscription") - }.launchIn(defaultScope) - } - -@Stable -data class MusicServiceState( - val queueVersion: Long, - val queueSize: Int, - val currentIndex: Int, - val currentItemId: UUID?, - val status: NowPlayingStatus, - val currentItemTitle: String?, - val loadingState: LoadingState = LoadingState.Pending, - val queuedIds: Set<UUID>, -) { - companion object { - val EMPTY = - MusicServiceState( - 0L, - 0, - 0, - null, - NowPlayingStatus.IDLE, - null, - LoadingState.Pending, - emptySet(), - ) - } -} - -enum class NowPlayingStatus { - PLAYING, - PAUSED, - IDLE, -} - -/** - * Listens to [Player] events and updates the [StateFlow] - */ -private class MusicPlayerListener( - private val player: Player, - private val state: MutableStateFlow<MusicServiceState>, -) : Player.Listener { - init { - Timber.v("MusicPlayerListener init") - state.update { - it.copy( - queueSize = player.mediaItemCount, - currentIndex = player.currentMediaItemIndex, - status = if (player.isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.IDLE, - ) - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - Timber.v("MusicPlayerListener onIsPlayingChanged") - state.update { - it.copy( - status = if (isPlaying) NowPlayingStatus.PLAYING else NowPlayingStatus.PAUSED, - ) - } - } - - override fun onMediaItemTransition( - mediaItem: MediaItem?, - reason: Int, - ) { - Timber.v("MusicPlayerListener onMediaItemTransition") - updateCurrentIndex() - } - - override fun onTimelineChanged( - timeline: Timeline, - reason: Int, - ) { -// Timber.v("MusicPlayerListener onTimelineChanged") - if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { - updateCurrentIndex() - } - } - - private fun updateCurrentIndex() { - state.update { state -> - player.currentMediaItemIndex.takeIf { it >= 0 }?.let { currentMediaItemIndex -> - if (currentMediaItemIndex in (0..<player.mediaItemCount)) { - val item = - player.getMediaItemAt(currentMediaItemIndex).localConfiguration?.tag as? AudioItem - state.copy( - currentIndex = currentMediaItemIndex, - currentItemId = player.getMediaItemAt(currentMediaItemIndex).mediaId.toUUIDOrNull(), - currentItemTitle = item?.title, - ) - } else { - state - } - } ?: state - } - } -} - -/** - * Remember the queue currently playing - * - * @see MusicServiceState - */ -@Composable -fun rememberQueue( - player: Player, - queueVersion: Long, - queueSize: Int, -): List<AudioItem> = - remember(queueVersion, queueSize) { - object : AbstractList<AudioItem>() { - override val size: Int - get() = player.mediaItemCount - - override fun get(index: Int): AudioItem = player.getMediaItemAt(index).localConfiguration?.tag as AudioItem - } - } 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 deleted file mode 100644 index ea45cd75..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ /dev/null @@ -1,263 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import com.github.damontecres.wholphin.data.ServerPreferencesDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.data.model.ServerUserConfig -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.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -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 -import org.jellyfin.sdk.model.api.UserDto -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.hours - -/** - * Gets the items to show in the nav drawer - */ -@Singleton -class NavDrawerService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - private val musicService: MusicService, - ) { - private val _state = MutableStateFlow(NavDrawerItemState()) - val state: StateFlow<NavDrawerItemState> = _state - - init { - // Handle updating the nav drawer when the user changes - 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, discoverActive) - } else { - _state.update { NavDrawerItemState() } - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error updating nav drawer") - showToast(context, "Error fetching user's views") - } - } - - // Handle when music is actively playing or not - coroutineScope.launchDefault { - musicService.state.collectLatest { music -> - Timber.v("MusicService updated") - when (music.status) { - NowPlayingStatus.PLAYING -> { - _state.update { - it.copy( - nowPlayingEnabled = true, - nowPlayingTitle = music.currentItemTitle, - ) - } - } - - NowPlayingStatus.IDLE -> { - _state.update { - it.copy( - nowPlayingEnabled = false, - nowPlayingTitle = null, - ) - } - } - - NowPlayingStatus.PAUSED -> { - delay(2.hours) - _state.update { - it.copy( - nowPlayingEnabled = false, - nowPlayingTitle = null, - ) - } - } - } - } - } - } - - /** - * Get all the libraries the user has access to - */ - suspend fun getAllUserLibraries( - userId: UUID, - tvAccess: Boolean, - ): List<Library> { - val userViews = - api.userViewsApi - .getUserViews(userId = userId) - .content.items - val recordingFolders = - if (tvAccess) { - 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 { - emptySet() - } - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - Library( - itemId = it.id, - name = it.name ?: "", - type = it.type, - collectionType = it.collectionType ?: CollectionType.UNKNOWN, - isRecordingFolder = it.id in recordingFolders, - ) - } - return libraries - } - - /** - * Get the libraries that the user has not "pinned". These will show in the More section. - */ - suspend fun getFilteredUserLibraries( - user: JellyfinUser, - tvAccess: Boolean, - ): List<Library> { - val pins = - serverPreferencesDao - .getNavDrawerPinnedItems(user) - .associateBy { it.itemId } - val libraries = - getAllUserLibraries(user.id, tvAccess) - .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } - return libraries - } - - /** - * Update the current state of the nav drawer items - */ - suspend fun updateNavDrawer( - user: JellyfinUser, - userDto: ServerUserConfig, - discoverActive: Boolean, - ) { - val builtins = - buildList { - add(NavDrawerItem.Favorites) - if (discoverActive) add(NavDrawerItem.Discover) - } - val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) - val libraries = - allLibraries - .map { - val destination = - if (it.isRecordingFolder) { - Destination.Recordings(it.itemId) - } else { - Destination.MediaItem( - it.itemId, - it.type, - it.collectionType, - ) - } - ServerNavDrawerItem( - itemId = it.itemId, - name = it.name, - destination = destination, - type = it.collectionType, - ) - } - val allItems = builtins + libraries - - val navDrawerPins = - withContext(WholphinDispatchers.IO) { - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } - } - - val items = mutableListOf<NavDrawerItem>() - val moreItems = mutableListOf<NavDrawerItem>() - allItems - // Sort by order if non-default, existing items before customize will have -1 value - // New items from the server will get Int.MAX_VALUE - // Items the user doesn't have access to anymore will be skipped - .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } - .forEach { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - if (pinned == NavPinType.PINNED) { - items.add(it) - } else { - moreItems.add(it) - } - } - - _state.update { - it.copy( - items = items, - moreItems = moreItems, - allLibraries = allLibraries, - ) - } - } - } - -data class NavDrawerItemState( - 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/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 3208f4ec..a387ac8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.services -import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.ui.nav.Destination import org.acra.ACRA import timber.log.Timber @@ -14,7 +14,7 @@ import javax.inject.Singleton class NavigationManager @Inject constructor() { - var backStack: MutableList<Destination> = NavBackStack(Destination.Home()) + var backStack: MutableList<NavKey> = mutableListOf() /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] @@ -29,7 +29,12 @@ class NavigationManager */ fun navigateToFromDrawer(destination: Destination) { goToHome() - backStack.add(destination) + if (destination == Destination.ServerList || destination is Destination.UserList) { + backStack.add(destination) + (0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) } + } else { + backStack.add(destination) + } log() } @@ -68,24 +73,9 @@ class NavigationManager log() } - /** - * Resets the backstack to the specified destination - */ - fun replace(destination: Destination) { - while (backStack.size > 1) { - backStack.removeLastOrNull() - } - if (backStack.isEmpty()) { - backStack.add(0, destination) - } else { - backStack[0] = destination - } - log() - } - private fun log() { val dest = backStack.lastOrNull().toString() - Timber.i("Current Destination: %s", dest) + Timber.Forest.i("Current Destination: %s", dest) ACRA.errorReporter.putCustomData("destination", dest) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index 1df73d18..b4967b03 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,23 +1,17 @@ package com.github.damontecres.wholphin.services -import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty -import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject import javax.inject.Singleton -/** - * Gets people in media, specifically to check if they are favorited or not - */ @Singleton class PeopleFavorites @Inject constructor( - @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -33,14 +27,7 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { - Person.fromDto( - context, - it, - favorites[it.id] ?: false, - api, - ) - } + people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } }.orEmpty() people } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 91c1e7cb..86906280 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject @@ -39,6 +40,9 @@ class PlaybackLifecycleObserver } override fun onStop(owner: LifecycleOwner) { + if (navigationManager.backStack.lastOrNull() is Destination.Playback) { + navigationManager.goBack() + } themeSongPlayer.stop() } } 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 a2fd0eb7..57dc781b 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 @@ -3,55 +3,21 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.os.Build -import android.os.Handler import androidx.annotation.OptIn -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C +import androidx.datastore.core.DataStore import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences -import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.RenderersFactory -import androidx.media3.exoplayer.mediacodec.MediaCodecSelector -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -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.extractor.ExtractorsFactory -import androidx.media3.session.MediaSession -import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.AppPreference 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.PlayerBackend -import com.github.damontecres.wholphin.preferences.get -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.util.BitstreamFilter -import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory -import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion -import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory -import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter -import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter -import com.github.damontecres.wholphin.util.WholphinDispatchers -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture +import com.github.damontecres.wholphin.util.mpv.MpvPlayer 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.withContext -import okhttp3.OkHttpClient +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import timber.log.Timber -import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton @@ -63,56 +29,38 @@ class PlayerFactory @Inject constructor( @param:ApplicationContext private val context: Context, - @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, - private val deviceProfileService: DeviceProfileService, + private val appPreferences: DataStore<AppPreferences>, ) { @Volatile var currentPlayer: Player? = null private set - suspend fun createVideoPlayer( - backend: PlayerBackend, - appPreferences: AppPreferences, - ): PlayerCreation { - 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() - } + fun createVideoPlayer(): Player { + if (currentPlayer?.isReleased == false) { + Timber.w("Player was not released before trying to create a new one!") + currentPlayer?.release() } - var assHandler: AssHandler? = null + + val prefs = runBlocking { appPreferences.data.firstOrNull()?.playbackPreferences } + val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue val newPlayer = when (backend) { - PlayerBackend.PREFER_MPV, - PlayerBackend.MPV, - -> { - val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding - val useGpuNext = prefs.mpvOptions.useGpuNext - MpvPlayer(context, enableHardwareDecoding, useGpuNext) + PlayerBackend.MPV -> { + val enableHardwareDecoding = + prefs?.mpvOptions?.enableHardwareDecoding + ?: AppPreference.MpvHardwareDecoding.defaultValue + MpvPlayer(context, enableHardwareDecoding) + .apply { + playWhenReady = true + } } PlayerBackend.EXO_PLAYER, PlayerBackend.UNRECOGNIZED, -> { - val extensions = prefs.overrides.mediaExtensionsEnabled - val useLibAss = - prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS - val decodeAv1 = prefs.overrides.decodeAv1 - val preferDolbyVision = - appPreferences.experimentalPreferences - .get { preferDolbyVisionOverHdr10Plus } ?: false - val convertDolbyVisionProfile7 = - DolbyVisionProfile7Conversion.isWanted( - appPreferences.experimentalPreferences, - deviceProfileService.mediaCodecCapabilitiesTest, - ) - Timber.v( - "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", - extensions, - prefs.overrides.assPlaybackMode, - convertDolbyVisionProfile7, - ) + val extensions = + runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled + Timber.v("extensions=$extensions") val rendererMode = when (extensions) { MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON @@ -120,180 +68,23 @@ class PlayerFactory MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON } - val dataSourceFactory = DefaultDataSource.Factory(context) - val extractorsFactory = createExtractorsFactory() - - // Applied outermost so that a profile 7 track is advertised as 8.1 whichever - // extractor, media3's own or libass's Matroska replacement, produced it - fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory = - if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this - var renderersFactory: RenderersFactory = - WholphinRenderersFactory( - context, - decodeAv1, - preferDolbyVision, - convertDolbyVisionProfile7, - ).setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode) - - val mediaSourceFactory = - if (useLibAss) { - val renderType = - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - AssRenderType.OVERLAY_CANVAS - } else { - AssRenderType.OVERLAY_OPEN_GL - } - assHandler = AssHandler(renderType) - val assSubtitleParserFactory = AssSubtitleParserFactory(assHandler) - renderersFactory = AssRenderersFactory(assHandler, renderersFactory) - DefaultMediaSourceFactory( - dataSourceFactory, - extractorsFactory - .withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ).forDolbyVision(), - ).setSubtitleParserFactory(assSubtitleParserFactory) - } else { - DefaultMediaSourceFactory( - dataSourceFactory, - extractorsFactory.forDolbyVision(), - ) - } - val disableAudioOffload = - appPreferences.experimentalPreferences.get { disableAudioOffload } ?: false - val tunneling = - appPreferences.experimentalPreferences.get { videoTunnelingEnabled } - val trackSelector = - createTrackSelector( - tunneling = tunneling, - disableAudioOffload = disableAudioOffload, - ) - ExoPlayer .Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) - .setTrackSelector(trackSelector) - .build() + .setRenderersFactory( + DefaultRenderersFactory(context) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() .apply { - assHandler?.init(this) - withContext(WholphinDispatchers.Main) { - setAudioAttributes( - AudioAttributes - .Builder() - .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) - .setUsage(C.USAGE_MEDIA) - .build(), - true, - ) - } + playWhenReady = true } } - - PlayerBackend.EXTERNAL_PLAYER -> { - throw IllegalArgumentException("Cannot create a player for external playback") - } } currentPlayer = newPlayer - return PlayerCreation(newPlayer, assHandler) + return newPlayer } - - fun createAudioPlayer( - disableAudioOffload: Boolean, - extensions: MediaExtensionStatus = MediaExtensionStatus.MES_FALLBACK, - ): ExoPlayer { - val rendererMode = - when (extensions) { - MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF - else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - } - val extractorsFactory = createExtractorsFactory() - val renderersFactory: RenderersFactory = - WholphinRenderersFactory(context, false) - .setEnableDecoderFallback(true) - .setExtensionRendererMode(rendererMode) - val mediaSourceFactory = - DefaultMediaSourceFactory( - OkHttpDataSource.Factory(authOkHttpClient), - extractorsFactory, - ) - val trackSelector = createTrackSelector(disableAudioOffload = disableAudioOffload) - return ExoPlayer - .Builder(context) - .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) - .setTrackSelector(trackSelector) - .build() - .also { - it.setAudioAttributes( - AudioAttributes - .Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true, - ) - } - } - - private fun createExtractorsFactory() = - DefaultExtractorsFactory() - .setConstantBitrateSeekingEnabled(true) - .setConstantBitrateSeekingAlwaysEnabled(true) - - 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) - .setCallback(TrustAllControllersCallback) - .build() } -/** - * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`) - * only get read-only player commands by default, so play/pause from them is silently dropped. - * Restore the pre-1.11 behavior of granting all player commands to every controller. - */ -@OptIn(UnstableApi::class) -object TrustAllControllersCallback : MediaSession.Callback { - override fun onConnectAsync( - session: MediaSession, - controller: MediaSession.ControllerInfo, - ): ListenableFuture<MediaSession.ConnectionResult> = - Futures.immediateFuture( - MediaSession.ConnectionResult - .AcceptedResultBuilder(session, controller) - .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) - .build(), - ) -} - val Player.isReleased: Boolean get() { return when (this) { @@ -302,94 +93,3 @@ val Player.isReleased: Boolean else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") } } - -data class PlayerCreation( - val player: Player, - val assHandler: AssHandler? = null, -) - -// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 -class WholphinRenderersFactory( - context: Context, - private val av1Enabled: Boolean, - private val preferDolbyVisionOverHdr10Plus: Boolean = false, - private val convertDolbyVisionProfile7: Boolean = false, -) : DefaultRenderersFactory(context) { - @OptIn(ExperimentalApi::class) - override fun buildVideoRenderers( - context: Context, - extensionRendererMode: Int, - mediaCodecSelector: MediaCodecSelector, - enableDecoderFallback: Boolean, - eventHandler: Handler, - eventListener: VideoRendererEventListener, - allowedVideoJoiningTimeMs: Long, - out: ArrayList<Renderer>, - ) { - // The conversion goes first: it may shorten the access unit, and the masking then only - // has to walk what is left of it - val bitstreamFilters = - buildList<BitstreamFilter> { - if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter()) - if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter()) - } - val videoCodecAdapterFactory = - if (bitstreamFilters.isNotEmpty()) { - BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) - } else { - codecAdapterFactory - } - var videoRendererBuilder = - MediaCodecVideoRenderer - .Builder(context) - .setCodecAdapterFactory(videoCodecAdapterFactory) - .setMediaCodecSelector(mediaCodecSelector) - .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) - .setEnableDecoderFallback(enableDecoderFallback) - .setEventHandler(eventHandler) - .setEventListener(eventListener) - .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) - .experimentalSetParseAv1SampleDependencies(false) - .experimentalSetLateThresholdToDropDecoderInputUs(C.TIME_UNSET) - if (Build.VERSION.SDK_INT >= 34) { - videoRendererBuilder = - videoRendererBuilder.experimentalSetEnableMediaCodecBufferDecodeOnlyFlag( - false, - ) - } - out.add(videoRendererBuilder.build()) - - if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { - return - } - var extensionRendererIndex = out.size - if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { - extensionRendererIndex-- - } - - if (av1Enabled) { - try { - val clazz = Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") - val constructor: Constructor<*> = - clazz.getConstructor( - Long::class.javaPrimitiveType, - Handler::class.java, - VideoRendererEventListener::class.java, - Int::class.javaPrimitiveType, - ) - val renderer = - constructor.newInstance( - allowedVideoJoiningTimeMs, - eventHandler, - eventListener, - MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, - ) as Renderer - out.add(extensionRendererIndex++, renderer) - Timber.i("Loaded Libdav1dVideoRenderer.") - } catch (e: Exception) { - // The extension is present, but instantiation failed. - throw java.lang.IllegalStateException("Error instantiating AV1 extension", e) - } - } - } -} 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 b1a48753..1b7e6d72 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 @@ -7,43 +7,37 @@ import com.github.damontecres.wholphin.data.model.BaseItem 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.DetailItemFields +import com.github.damontecres.wholphin.ui.DefaultItemFields 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.GetPlaylistItemsRequestHandler +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 import org.jellyfin.sdk.model.api.CreatePlaylistDto import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.LocationType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -/** - * Create [Playlist]s (not Jellyfin server playlist) for playback - * - * Used to create a queue of episodes or from Play All button - */ @Singleton class PlaylistCreator @Inject @@ -55,7 +49,7 @@ class PlaylistCreator /** * Creates a playlist of next up episodes for the given series starting with the given episode */ - private suspend fun createFromEpisode( + suspend fun createFromEpisode( seriesId: UUID, episodeId: UUID?, seasonId: UUID? = null, @@ -65,45 +59,35 @@ class PlaylistCreator GetEpisodesRequest( seriesId = seriesId, seasonId = seasonId, - fields = DetailItemFields, + fields = DefaultItemFields, startItemId = episodeId, sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, ) - val episodes = - GetEpisodesRequestHandler - .execute(api, request) - .content.items - .convertAndAddParts(false) + val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes.subList(startIndex, episodes.size)) + return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) } - /** - * Create from a server playlist ID - */ - private suspend fun createFromPlaylistId( + suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, - sortAndDirection: SortAndDirection, - filter: GetItemsFilter, + shuffled: Boolean, ): Playlist { val request = - filter.applyTo( - GetItemsRequest( - userId = serverRepository.currentUser?.id, - parentId = playlistId, - fields = DetailItemFields, - startIndex = startIndex, - limit = Playlist.MAX_SIZE, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - excludeLocationTypes = listOf(LocationType.VIRTUAL), - ), + GetPlaylistItemsRequest( + playlistId = playlistId, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, ) - val items = GetItemsRequestHandler.execute(api, request).content.items - return Playlist(items.convertAndAddParts()) + val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items + var baseItems = items.map { BaseItem.from(it, api) } + if (shuffled) { + baseItems = baseItems.shuffled() + } + return Playlist(baseItems, 0) } private suspend fun createFromCollection( @@ -124,49 +108,37 @@ class PlaylistCreator req = GetItemsRequest( parentId = item.id, - enableImageTypes = - listOf( - ImageType.PRIMARY, - ImageType.THUMB, - ImageType.LOGO, - ), + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), includeItemTypes = includeItemTypes, recursive = true, excludeItemIds = listOf(item.id), sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) }, sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) }, - fields = DetailItemFields, + fields = DefaultItemFields, startIndex = startIndex, limit = Playlist.MAX_SIZE, - excludeLocationTypes = listOf(LocationType.VIRTUAL), ), ) val items = - GetItemsRequestHandler - .execute(api, request) - .content.items - .convertAndAddParts() - return Playlist(items) + GetItemsRequestHandler.execute(api, request).content.items.map { + BaseItem.from(it, api) + } + return Playlist(items, 0) } - /** - * Create a [Playlist] contextually based on the given item. - * - * For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed - */ suspend fun createFrom( item: BaseItemDto, startIndex: Int = 0, - sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT, - shuffled: Boolean = false, - recursive: Boolean = false, - filter: GetItemsFilter = GetItemsFilter(), + sortAndDirection: SortAndDirection?, + shuffled: Boolean, + recursive: Boolean, + filter: GetItemsFilter, ): PlaylistCreationResult = when (item.type) { BaseItemKind.BOX_SET, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.USER_VIEW, - -> { + -> PlaylistCreationResult.Success( createFromCollection( item = item, @@ -180,7 +152,6 @@ class PlaylistCreator filter = filter, ), ) - } BaseItemKind.EPISODE -> { val seriesId = item.seriesId @@ -194,120 +165,44 @@ class PlaylistCreator ), ) } else { - PlaylistCreationResult.Error(null, "Episode has no seriesId") + PlaylistCreationResult.Error(null, "Episode has not seriesId") } } BaseItemKind.SEASON -> { val seriesId = item.seriesId if (seriesId != null) { - 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, - ), - ) - } + PlaylistCreationResult.Success( + createFromEpisode( + seriesId = seriesId, + seasonId = item.id, + episodeId = null, + shuffled = shuffled, + ), + ) } else { - PlaylistCreationResult.Error(null, "Episode has no seriesId") + PlaylistCreationResult.Error(null, "Episode has not seriesId") } } - BaseItemKind.SERIES -> { - 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 -> { + BaseItemKind.SERIES -> PlaylistCreationResult.Success( - createFromPlaylistId( - playlistId = item.id, - startIndex = startIndex, - sortAndDirection = - if (shuffled) { - SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) - } else { - sortAndDirection ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - }, - filter = filter, + createFromEpisode( + seriesId = item.id, + seasonId = null, + episodeId = null, + shuffled = shuffled, ), ) - } - BaseItemKind.MOVIE, - BaseItemKind.VIDEO, - BaseItemKind.MUSIC_VIDEO, - BaseItemKind.TRAILER, - -> { - val list = - buildList { - add(PlaylistItem.Media(BaseItem(item, false))) - - if (item.partCount.gt(1)) { - api.videosApi - .getAdditionalPart(item.id) - .content.items - .map { - PlaylistItem.Media(BaseItem(it, false)) - }.let(::addAll) - } - } - PlaylistCreationResult.Success(Playlist(list)) - } + BaseItemKind.PLAYLIST -> + PlaylistCreationResult.Success( + createFromPlaylistId( + item.id, + startIndex, + shuffled, + ), + ) // Not support yet // BaseItemKind.AGGREGATE_FOLDER -> TODO() @@ -317,81 +212,37 @@ class PlaylistCreator // BaseItemKind.MUSIC_ALBUM -> TODO() // BaseItemKind.MUSIC_ARTIST -> TODO() - else -> { - PlaylistCreationResult.Error(null, "Unsupported type: ${item.type}") - } + else -> PlaylistCreationResult.Error(null, "Unsupported type: ${item.type}") } - private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<PlaylistItem> = - buildList { - this@convertAndAddParts - .filter { it.locationType != LocationType.VIRTUAL } - .forEach { ep -> - add(PlaylistItem.Media(BaseItem(ep, useSeriesForPrimary))) - if (ep.partCount.gt(1)) { - val parts = - api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> - PlaylistItem.Media(BaseItem(part, useSeriesForPrimary)) - } - addAll(parts) - } - } - } - - /** - * Get the playlists on the server for a given media type - */ suspend fun getServerPlaylists( - query: String, mediaType: MediaType?, - ): List<PlaylistInfo> { - val userId = - serverRepository.currentUser?.id ?: throw IllegalStateException("No user found") + scope: CoroutineScope, + ): List<PlaylistInfo?> { 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 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 - } - } + 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, + ) } -// .sortedWith( -// compareBy<PlaylistInfo> { -// SearchRelevance.score(it.name, BaseItemKind.PLAYLIST, query) -// }.thenBy { it.name }, -// ) + } } suspend fun createServerPlaylist( name: String, initialItems: List<UUID>, ): UUID? = - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> api.playlistsApi .createPlaylist( CreatePlaylistDto( 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 deleted file mode 100644 index 174c103b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ /dev/null @@ -1,239 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import android.hardware.display.DisplayManager -import android.os.Build -import android.os.Handler -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.delay -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.MediaStreamType -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.seconds - -/** - * Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media - */ -@Singleton -class RefreshRateService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - ) { - /** - * Find the best display mode for the given stream and signal to change to it - */ - suspend fun changeRefreshRate( - stream: MediaStream, - switchRefreshRate: Boolean, - switchResolution: Boolean, - ) = withContext(WholphinDispatchers.IO) { - if (!switchRefreshRate && !switchResolution) { - Timber.v("Not switching either refresh rate nor resolution") - return@withContext - } - val displayManager = - MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - val displayModes = - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - - val currentDisplayMode = display.mode - require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } - val width = stream.width - val height = stream.height - val frameRate = - if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate - if (width == null || height == null || frameRate == null) { - Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) - return@withContext - } - Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) - val targetMode = - findDisplayMode( - displayModes = displayModes, - streamWidth = width, - streamHeight = height, - targetFrameRate = frameRate, - refreshRateSwitch = switchRefreshRate, - resolutionSwitch = switchResolution, - ) - Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode) - if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) { - val listener = Listener(display.displayId) - displayManager.registerDisplayListener( - listener, - Handler(Looper.myLooper() ?: Looper.getMainLooper()), - ) - try { - MainActivity.instance.changeDisplayMode(targetMode.modeId) - val result = - withTimeoutOrNull(5.seconds) { - listener.deferred.await() - } - if (result == null) { - Timber.w("Timed out waiting for display change") - showToast(context, "Refresh rate switch is taking a long time") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception waiting for refresh rate switch") - } finally { - displayManager.unregisterDisplayListener(listener) - } - val targetRate = (targetMode.refreshRate * 1000).roundToInt() - val isSeamless = - targetRate == (currentDisplayMode.refreshRate * 1000).roundToInt() || - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - currentDisplayMode.alternativeRefreshRates - .map { (it * 1000).roundToInt() } - .any { it % targetRate == 0 } - } else { - false - } - if (!isSeamless) { - Timber.v("Waiting for non-seamless switch") - // Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate) - delay(2.seconds) - } - } - } - - /** - * Reset the display mode to the original - */ - fun resetRefreshRate() { - MainActivity.instance.changeDisplayMode(0) - } - - /** - * Listens for the display to change so we known the refresh rate or resolution is updated - */ - private class Listener( - val displayId: Int, - ) : DisplayManager.DisplayListener { - val deferred = CompletableDeferred<Unit>() - - override fun onDisplayAdded(displayId: Int) { - } - - override fun onDisplayChanged(displayId: Int) { - if (displayId == this.displayId) { - Timber.v("Got display change for $displayId") - deferred.complete(Unit) - } - } - - override fun onDisplayRemoved(displayId: Int) { - } - } - - companion object { - /** - * Find the best display mode for the given stream & preferences - * - * @param displayModes candidates that are sorted by resolution and frame rate descending - */ - fun findDisplayMode( - displayModes: List<DisplayMode>, - streamWidth: Int, - streamHeight: Int, - targetFrameRate: Float, - refreshRateSwitch: Boolean, - resolutionSwitch: Boolean, - ): DisplayMode? { - val streamRate = targetFrameRate.times(1000).roundToInt() -// Timber.v("display modes: %s", displayModes.joinToString("\n")) - val candidates = - if (refreshRateSwitch) { - displayModes - .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - .filter { frameRateMatches(it.refreshRateRounded, streamRate) } - } else { - displayModes - .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - } -// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) - return if (!resolutionSwitch) { - candidates - .filter { it.refreshRateRounded == streamRate } - .maxByOrNull { it.physicalWidth * it.physicalHeight } - ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } - } else { - // Exact resolution & frame rate - candidates.firstOrNull { - it.physicalWidth == streamWidth && - it.physicalHeight == streamHeight && - it.refreshRateRounded == streamRate - } - // Next highest resolution, exact frame rate - ?: candidates.lastOrNull { - it.physicalWidth >= streamWidth && - it.physicalHeight >= streamHeight && - it.refreshRateRounded == streamRate - } - // Exact resolution, acceptable frame rate - ?: candidates.lastOrNull { - it.physicalWidth == streamWidth && - it.physicalHeight == streamHeight && - frameRateMatches(it.refreshRateRounded, streamRate) - } - // Next highest resolution, acceptable frame rate - ?: candidates.lastOrNull { - it.physicalWidth >= streamWidth && - it.physicalHeight >= streamHeight && - frameRateMatches(it.refreshRateRounded, streamRate) - } - // Fall back to largest resolution, exact frame rate - ?: candidates - .filter { it.refreshRateRounded == streamRate } - .maxByOrNull { it.physicalWidth * it.physicalHeight } - // Finally, just the highest resolution - ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } - } - } - - fun frameRateMatches( - refreshRateRounded: Int, - streamRate: Int, - ): Boolean { - return refreshRateRounded % streamRate == 0 || // Exact multiple - refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps - } - } - } - -/** - * Wrapper for a [Display.Mode] - */ -data class DisplayMode( - val modeId: Int, - val physicalWidth: Int, - val physicalHeight: Int, - val refreshRate: Float, -) { - val refreshRateRounded: Int = (refreshRate * 1000).roundToInt() - - constructor(mode: Display.Mode) : this( - mode.modeId, - mode.physicalWidth, - mode.physicalHeight, - mode.refreshRate, - ) -} 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 deleted file mode 100644 index a3b5b795..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RememberedTabService.kt +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index c3fcb478..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ /dev/null @@ -1,315 +0,0 @@ -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 -import com.github.damontecres.wholphin.ui.components.ScreensaverItem -import com.github.damontecres.wholphin.ui.formatDate -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.WholphinDispatchers -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.libraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.milliseconds - -/** - * Handles the queue of items to show on the screensaver, both in-app or OS - */ -@Singleton -class ScreensaverService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - @param:DefaultCoroutineScope private val scope: CoroutineScope, - private val api: ApiClient, - private val userPreferencesService: UserPreferencesService, - private val imageUrlService: ImageUrlService, - ) { - private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) - val state: StateFlow<ScreensaverState> = _state - - val keepScreenOn = MutableStateFlow(false) - - private var waitJob: Job? = null - private var dimJob: Job? = null - - init { - userPreferencesService.flow - .onEach { prefs -> - _state.update { - val enabled = - prefs.appPreferences.interfacePreferences.screensaverPreference.enabled - keepScreenOnInternal(enabled) - ScreensaverState( - enabled = enabled, - enabledTemp = false, - active = false, - paused = false, - dimEnabled = prefs.appPreferences.interfacePreferences.screensaverPreference.dimEnabled, - dimActive = false, - ) - } - }.launchIn(scope) - } - - /** - * Reset the timer before showing the in-app screensaver - */ - fun pulse() { - waitJob?.cancel() - if (_state.value.enabled) { -// Timber.v("pulse") - _state.update { - if (!it.active) { - it.copy( - active = false, - dimActive = false, - ) - } else { - it - } - } - - if (!_state.value.paused) { - waitJob = - scope.launch(ExceptionHandler()) { - val startDelay = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds - delay(startDelay) - _state.update { - it.copy( - active = true, - 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) - } - } - } - } - } - - /** - * Immediately start the in-app screensaver - */ - fun start() { - _state.update { - it.copy( - enabledTemp = true, - active = true, - dimActive = true, - ) - } - } - - /** - * Immediately stop the in-app screensaver - */ - fun stop(cancelJob: Boolean) { - _state.update { - it.copy( - enabledTemp = false, - active = false, - dimActive = false, - ) - } - if (cancelJob) { - waitJob?.cancel() - dimJob?.cancel() - } - } - - /** - * Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active - */ - fun keepScreenOn(keep: Boolean) { - scope.launchDefault { - val screensaverEnabled = state.value.enabled - val dimEnabled = state.value.dimEnabled - Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, 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, - dimActive = false, - paused = keep, - ) - } - if (!keep) { - pulse() - } - } - if (!screensaverEnabled) { - // If in-app screensaver is not enabled, send keep screen on to the OS - keepScreenOnInternal(keep) - } - } - } - - private fun keepScreenOnInternal(keep: Boolean) { - keepScreenOn.update { keep } - } - - /** - * Create a flow of items to show on the screensaver - */ - fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> = - flow { - val pager = - try { - createPager() - } catch (ex: Exception) { - Timber.e(ex, "Error creating pager for screensaver") - emit(ScreensaverItem.Error(ex)) - return@flow - } - Timber.v("Got %s items", pager.size) - var index = 0 - if (pager.isEmpty()) { - emit(ScreensaverItem.Empty) - } else { - val duration = - userPreferencesService - .getCurrent() - .appPreferences - .interfacePreferences.screensaverPreference.duration.milliseconds - while (true) { - try { - val item = pager.getBlocking(index) - Timber.v("Next index=%s, item=%s", index, item?.id) - if (item != null) { - val backdropUrl = - if (item.type == BaseItemKind.PHOTO) { - api.libraryApi.getDownloadUrl(item.id) - } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - } - val title = - if (item.type == BaseItemKind.PHOTO) { - item.data.premiereDate?.let { - formatDate(it.toLocalDate()) - } - } else { - item.title - } - val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) - if (backdropUrl != null) { - context.imageLoader - .enqueue( - ImageRequest - .Builder(context) - .data(backdropUrl) - .build(), - ).job - .await() - emit( - ScreensaverItem.CurrentItem( - item, - backdropUrl, - logoUrl, - title ?: "", - ), - ) - delay(duration) - } - } - } catch (_: CancellationException) { - break - } catch (ex: Exception) { - Timber.e(ex, "Error fetching next item") - delay(duration) - } - index++ - if (index > pager.lastIndex) index = 0 - } - } - }.flowOn(WholphinDispatchers.Default).cancellable() - - private suspend fun createPager(): ApiRequestPager<GetItemsRequest> { - val prefs = - userPreferencesService.flow - .first() - .appPreferences - .interfacePreferences.screensaverPreference - val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } - val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } - val request = - GetItemsRequest( - recursive = true, - includeItemTypes = itemTypes, - imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), - sortBy = listOf(ItemSortBy.RANDOM), - maxOfficialRating = maxAge?.toString(), - hasParentalRating = maxAge?.let { true }, - ) - 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( - val enabled: Boolean, - 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/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt deleted file mode 100644 index 685b858c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl -import okhttp3.OkHttpClient - -/** - * Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead. - */ -class SeerrApi( - private val okHttpClient: OkHttpClient, -) { - var api: SeerrApiClient = - SeerrApiClient( - baseUrl = "", - apiKey = null, - okHttpClient = okHttpClient, - ) - private set - - val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && BuildConfig.DISCOVER_ENABLED - - fun update( - baseUrl: String, - apiKey: String?, - ) { - api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt deleted file mode 100644 index 08d31a9d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ /dev/null @@ -1,411 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.google.common.cache.CacheBuilder -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.mapLatest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber -import java.util.Optional -import java.util.UUID -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import javax.inject.Singleton - -/** - * The TMDB user vote average & count as reported by Seerr's movie/TV details - */ -data class TmdbVote( - val average: Double?, - val count: Long?, -) - -/** - * The Seerr title whose ratings are shown for a Jellyfin item - */ -data class RatingsTarget( - val type: SeerrItemType, - val tmdbId: Int, -) - -/** - * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings - * reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings]. - * - * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: - * - movies show their own ratings - * - series show their own ratings - * - seasons show their series' ratings, as the PWA does for the title page a season belongs to - * - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season - * Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it. - * - everything else (collections, music, photos, programs, ...) has none - * - * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from - * `voteAverage`/`voteCount` and is only requested when no other source has a score. - * - * Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@Singleton -class SeerrRatingsService - @Inject - constructor( - private val seerrService: SeerrService, - private val api: ApiClient, - ) { - private data class Key( - val type: SeerrItemType, - val tmdbId: Int, - ) - - private val cache = - CacheBuilder - .newBuilder() - .maximumSize(CACHE_SIZE) - .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) - .build<Key, SourceRatings>() - - private data class SeasonKey( - val tmdbId: Int, - val season: Int, - ) - - /** - * The TMDB vote of each episode (by episode number) of a series' season - */ - private val seasonCache = - CacheBuilder - .newBuilder() - .maximumSize(CACHE_SIZE) - .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) - .build<SeasonKey, Map<Int, TmdbVote>>() - - /** - * The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page - * don't carry their series' provider ids, so this is how they are found. - */ - private val tmdbIds = - CacheBuilder - .newBuilder() - .maximumSize(CACHE_SIZE) - .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) - .build<UUID, Optional<Int>>() - - fun cached( - type: SeerrItemType, - tmdbId: Int, - ): SourceRatings? = cache.getIfPresent(Key(type, tmdbId)) - - /** - * The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings. - * An episode's or season's own provider ids are for the episode or season, so those use the series. - */ - private fun ownerId(item: BaseItem): UUID? = - when (item.type) { - BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id - BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId - else -> null - } - - /** - * The TMDB id if it is known without a request: the item's own provider ids or an earlier lookup. - * Present but empty means the item definitely has no TMDB id, null means unknown. - */ - private fun knownTmdbId( - item: BaseItem, - ownerId: UUID, - ): Optional<Int>? = - item.data.providerIds - ?.takeIf { ownerId == item.id && it.isNotEmpty() } - ?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) } - ?: tmdbIds.getIfPresent(ownerId) - - private fun type(item: BaseItem): SeerrItemType? = - when (item.type) { - BaseItemKind.MOVIE -> SeerrItemType.MOVIE - BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV - else -> null - } - - private fun knownTarget(item: BaseItem): RatingsTarget? { - val type = type(item) ?: return null - val owner = ownerId(item) ?: return null - return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) } - } - - /** - * Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand. - * Null if the item has no ratings, no TMDB id or the lookup failed. - */ - suspend fun resolveTarget(item: BaseItem): RatingsTarget? { - val type = type(item) ?: return null - val owner = ownerId(item) ?: return null - val tmdb = - knownTmdbId(item, owner) ?: try { - Optional - .ofNullable( - api.userLibraryApi - .getItem(owner) - .content.providerIds - ?.get("Tmdb") - ?.toIntOrNull(), - ).also { tmdbIds.put(owner, it) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.w(ex, "Error fetching provider ids for %s", owner) - return null - } - return tmdb.orElse(null)?.let { RatingsTarget(type, it) } - } - - /** - * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty - */ - fun cachedFor(item: BaseItem): SourceRatings? { - val target = knownTarget(item) ?: return null - val series = cached(target.type, target.tmdbId) ?: return null - if (item.type != BaseItemKind.EPISODE) return series - val season = item.data.parentIndexNumber - val episode = item.data.indexNumber - if (season == null || episode == null) return series - val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null - return series.withEpisode(episodeScore(votes[episode])) - } - - private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) } - - /** - * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established - * asynchronously after login. - * - * Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so - * collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on: - * collecting is cancelled for the previous item, which cancels the wait or the request in progress. - * - * Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed. - */ - fun forItem( - item: BaseItem?, - debounceMillis: Long = DEBOUNCE_MILLIS, - ): Flow<SourceRatings?> { - if (item == null || type(item) == null) return flowOf(null) - cachedFor(item)?.let { return flowOf(it) } - val owner = ownerId(item) ?: return flowOf(null) - if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null) - return flow { - delay(debounceMillis) - emitAll( - seerrService.active - .distinctUntilChanged() - .mapLatest { active -> - if (active) resolveTarget(item)?.let { getFor(item, it) } else null - }, - ) - } - } - - /** - * The ratings for the target of an item: for an episode, those of its series with its own TMDB score - */ - private suspend fun getFor( - item: BaseItem, - target: RatingsTarget, - ): SourceRatings? { - val series = get(target.type, target.tmdbId) - val season = item.data.parentIndexNumber - val episode = item.data.indexNumber - if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series - val votes = getSeason(target.tmdbId, season) - if (series == null && votes == null) return null - return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode))) - } - - /** - * The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached. - */ - private suspend fun getSeason( - tmdbId: Int, - season: Int, - ): Map<Int, TmdbVote>? { - val key = SeasonKey(tmdbId, season) - seasonCache.getIfPresent(key)?.let { return it } - val votes = - try { - seerrService.api.tvApi - .tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season) - .episodes - .orEmpty() - .mapNotNull { episode -> - episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) } - }.toMap() - } catch (ex: CancellationException) { - throw ex - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - // TMDB has no such season, that is an answer rather than a failure - emptyMap() - } else { - Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) - return null - } - } catch (ex: Exception) { - Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) - return null - } - seasonCache.put(key, votes) - return votes - } - - /** - * @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request - * @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now - */ - suspend fun get( - type: SeerrItemType, - tmdbId: Int, - knownTmdb: TmdbVote? = null, - ): SourceRatings? { - val key = Key(type, tmdbId) - cache.getIfPresent(key)?.let { return it } - if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null - - val combined = - try { - fetchCombined(type, tmdbId) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId) - return null - } - var ratings = build(combined, null) - if (ratings.isEmpty) { - // Nothing else has a score, so TMDB is the only possible source - val vote = - knownTmdb ?: try { - fetchTmdb(type, tmdbId) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId) - return null - } - ratings = build(combined, vote) - } - cache.put(key, ratings) - return ratings - } - - private suspend fun fetchCombined( - type: SeerrItemType, - tmdbId: Int, - ): Combined = - try { - when (type) { - SeerrItemType.MOVIE -> { - Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId)) - } - - else -> { - Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId)) - } - } - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - // Seerr has no RT/IMDb data for this title, that is an answer rather than a failure - Timber.d("No ratings for %s %s", type, tmdbId) - Combined.None - } else { - throw ex - } - } - - private suspend fun fetchTmdb( - type: SeerrItemType, - tmdbId: Int, - ): TmdbVote = - when (type) { - SeerrItemType.MOVIE -> { - seerrService.api.moviesApi - .movieMovieIdGet(movieId = tmdbId) - .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } - } - - else -> { - seerrService.api.tvApi - .tvTvIdGet(tvId = tmdbId) - .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } - } - } - - private fun build( - combined: Combined, - tmdb: TmdbVote?, - ): SourceRatings = - when (combined) { - is Combined.Movie -> buildMovie(combined.response, tmdb) - is Combined.Tv -> { - val rt = combined.response - SourceRatings.of( - rtCriticsScore = rt.criticsScore, - rtCriticsLabel = rt.criticsRating, - rtAudienceScore = rt.audienceScore, - rtAudienceLabel = rt.audienceRating, - tmdbScore = tmdb?.average, - tmdbVotes = tmdb?.count, - ) - } - - Combined.None -> { - SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count) - } - } - - private fun buildMovie( - response: MovieMovieIdRatingscombinedGet200Response, - tmdb: TmdbVote?, - ): SourceRatings = - SourceRatings.of( - rtCriticsScore = response.rt?.criticsScore, - rtCriticsLabel = response.rt?.criticsRating, - rtAudienceScore = response.rt?.audienceScore, - rtAudienceLabel = response.rt?.audienceRating, - imdbScore = response.imdb?.criticsScore, - imdbVotes = response.imdb?.criticsScoreCount, - tmdbScore = tmdb?.average, - tmdbVotes = tmdb?.count, - ) - - private sealed interface Combined { - data class Movie( - val response: MovieMovieIdRatingscombinedGet200Response, - ) : Combined - - data class Tv( - val response: TvTvIdRatingsGet200Response, - ) : Combined - - data object None : Combined - } - - companion object { - private const val CACHE_SIZE = 300L - private const val CACHE_HOURS = 6L - const val DEBOUNCE_MILLIS = 350L - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt deleted file mode 100644 index 0d0eb0b6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ /dev/null @@ -1,358 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest -import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest -import com.github.damontecres.wholphin.api.seerr.model.PublicSettings -import com.github.damontecres.wholphin.api.seerr.model.User -import com.github.damontecres.wholphin.data.SeerrServerDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod -import com.github.damontecres.wholphin.data.model.SeerrPermission -import com.github.damontecres.wholphin.data.model.SeerrPluginLogin -import com.github.damontecres.wholphin.data.model.SeerrPluginSettings -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser -import com.github.damontecres.wholphin.data.model.hasPermission -import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl -import com.github.damontecres.wholphin.util.LoadingState -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.time.Duration.Companion.seconds - -/** - * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. - */ -@Singleton -class SeerrServerRepository - @Inject - constructor( - private val seerrApi: SeerrApi, - private val seerrServerDao: SeerrServerDao, - private val serverRepository: ServerRepository, - @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, - ) { - private val _connection = MutableStateFlow<SeerrState>(SeerrState()) - val connection: StateFlow<SeerrState> = _connection - - val current: Flow<CurrentSeerr?> = - _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } - val currentServer: Flow<SeerrServer?> = - connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } - val currentUser: Flow<SeerrUser?> = - connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } - val currentUserId: Flow<Int?> = current.map { it?.config?.id } - - /** - * Whether Seerr integration is currently active of not - */ - val active: Flow<Boolean> = - connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } - - fun clear() { - _connection.update { SeerrState() } - seerrApi.update("", null) - } - - fun error( - server: SeerrServer, - user: SeerrUser, - exception: Exception, - ) { - _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } - seerrApi.update("", null) - } - - suspend fun set( - server: SeerrServer, - user: SeerrUser, - userConfig: SeerrUserConfig, - ) { - val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() - _connection.update { - it.copy( - connection = - SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), - ) - } - } - - suspend fun addAndChangeServer( - url: String, - apiKey: String, - ) { - var server = seerrServerDao.getServer(url) - if (server == null) { - seerrServerDao.addServer(SeerrServer(url = url)) - server = seerrServerDao.getServer(url) - } - server?.server?.let { server -> - serverRepository.currentUser?.let { jellyfinUser -> - // TODO test api key - val user = - SeerrUser( - jellyfinUserRowId = jellyfinUser.rowId, - serverId = server.id, - authMethod = SeerrAuthMethod.API_KEY, - username = null, - password = null, - credential = apiKey, - ) - seerrServerDao.addUser(user) - - seerrApi.update(server.url, apiKey) - val userConfig = seerrApi.api.usersApi.authMeGet() - set(server, user, userConfig) - } - } - } - - suspend fun addAndChangeServer( - url: String, - authMethod: SeerrAuthMethod, - username: String, - password: String, - ) { - var server = seerrServerDao.getServer(url) - if (server == null) { - seerrServerDao.addServer(SeerrServer(url = url)) - server = seerrServerDao.getServer(url) - } - server?.server?.let { server -> - serverRepository.currentUser?.let { jellyfinUser -> - // TODO Need to update server early so that cookies are saved - seerrApi.update(server.url, null) - val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) - - val user = - SeerrUser( - jellyfinUserRowId = jellyfinUser.rowId, - serverId = server.id, - authMethod = authMethod, - username = username, - password = password, - credential = null, - ) - seerrServerDao.addUser(user) - set(server, user, userConfig) - } - } - } - - suspend fun testConnection( - authMethod: SeerrAuthMethod, - url: String, - username: String?, - passwordOrApiKey: String, - ): LoadingState { - val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } - val api = - SeerrApiClient( - createSeerrApiUrl(url), - apiKey, - okHttpClient - .newBuilder() - .connectTimeout(2.seconds) - .readTimeout(6.seconds) - .build(), - ) - seerrLogin(api, authMethod, username, passwordOrApiKey) - return LoadingState.Success - } - - suspend fun removeServerForCurrentUser(): Boolean { - val user = - when (val conn = connection.value.connection) { - SeerrConnectionStatus.NotConfigured -> return false - is SeerrConnectionStatus.Error -> conn.user - is SeerrConnectionStatus.Success -> conn.current.user - } - val rows = seerrServerDao.deleteUser(user) - clear() - return rows > 0 - } - - /** - * Given the [SeerrPluginSettings], try to connect to the Seerr server - * - * @return true if successfully updated the SeerrApi and logged in - */ - suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { - val currentUser = serverRepository.currentUser ?: return false - if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { - val server = - SeerrServer( - id = -1, - url = settings.serverUrl, - ) - val user = - SeerrUser( - jellyfinUserRowId = currentUser.rowId, - serverId = server.id, - authMethod = - when (settings.login) { - is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY - is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL - }, - username = - when (settings.login) { - is SeerrPluginLogin.ApiKey -> null - is SeerrPluginLogin.Local -> settings.login.local.username - }, - password = null, - credential = null, - ) - try { - val userConfig = - when (settings.login) { - is SeerrPluginLogin.ApiKey -> { - Timber.v("maybeAddPluginServer, using API key") - seerrApi.update(settings.serverUrl, settings.login.apiKey) - seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) - } - - is SeerrPluginLogin.Local -> { - Timber.v("maybeAddPluginServer, using local user") - seerrApi.update(settings.serverUrl, null) - seerrLogin( - seerrApi.api, - SeerrAuthMethod.LOCAL, - settings.login.local.username, - settings.login.local.password, - ) - } - - SeerrPluginLogin.None -> { - return false - } - } - // Success - _connection.update { it.copy(pluginProvided = true) } - set(server, user, userConfig) - return true - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - clear() - Timber.w(ex, "Error adding plugin provided seerr") - _connection.update { - it.copy( - pluginProvided = true, - connection = SeerrConnectionStatus.Error(server, user, ex), - ) - } - return false - } - } - return false - } - } - -/** - * A [SeerrUser] config - */ -typealias SeerrUserConfig = User - -data class SeerrState( - val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, - val pluginProvided: Boolean = false, -) - -sealed interface SeerrConnectionStatus { - data object NotConfigured : SeerrConnectionStatus - - data class Error( - val server: SeerrServer, - val user: SeerrUser, - val ex: Exception, - ) : SeerrConnectionStatus - - data class Success( - val current: CurrentSeerr, - ) : SeerrConnectionStatus -} - -data class CurrentSeerr( - val server: SeerrServer, - val user: SeerrUser, - val config: SeerrUserConfig, - val serverConfig: PublicSettings, -) { - val requestMovieEnabled: Boolean - get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE) - val request4kMovieEnabled: Boolean - get() = - (serverConfig.movie4kEnabled ?: false) && - config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) - - val requestTvEnabled: Boolean - get() = config.hasPermission(SeerrPermission.REQUEST_TV) - val request4kTvEnabled: Boolean - get() = - (serverConfig.series4kEnabled ?: false) && - config.hasPermission(SeerrPermission.REQUEST_4K_TV) -} - -suspend fun seerrLogin( - client: SeerrApiClient, - authMethod: SeerrAuthMethod, - username: String?, - password: String?, -): User = - when (authMethod) { - SeerrAuthMethod.LOCAL -> { - client.authApi.authLocalPost( - AuthLocalPostRequest( - email = username ?: "", - password = password ?: "", - ), - ) - client.usersApi.authMeGet() - } - - SeerrAuthMethod.JELLYFIN -> { - client.authApi.authJellyfinPost( - AuthJellyfinPostRequest( - username = username ?: "", - password = password ?: "", - ), - ) - client.usersApi.authMeGet() - } - - SeerrAuthMethod.API_KEY -> { - client.usersApi.authMeGet() - } - } - -fun CurrentSeerr?.imageUrlBuilder( - imageType: ImageType, - path: String?, -): String? { - if (this == null) return null - val cacheImages = serverConfig.cacheImages == true - val base = - if (cacheImages) { - server.url.removeSuffix("/") + "/imageproxy/tmdb" - } else { - "https://image.tmdb.org" - } - 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 "${base}${prefix}$path" -} 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 deleted file mode 100644 index d60988a2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ /dev/null @@ -1,506 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.api.seerr.model.CreditCast -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 -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 - -typealias SeerrSearchResult = SearchGet200ResponseResultsInner - -/** - * Main access for the current Seerr server (if any) - * - * Exposes a [SeerrApiClient] for queries - */ -@Singleton -class SeerrService - @Inject - constructor( - private val seerApi: SeerrApi, - private val seerrServerRepository: SeerrServerRepository, - private val imageUrlService: ImageUrlService, - ) { - val api: SeerrApiClient get() = seerApi.api - - val active get() = seerrServerRepository.active - - suspend fun search( - query: String, - page: Int = 1, - ): List<SeerrSearchResult> = - api.searchApi - .searchGet(query = query, page = page) - .results - .orEmpty() - - suspend fun discoverTv(page: Int = 1): List<DiscoverItem> = - api.searchApi - .discoverTvGet(page = page) - .results - ?.map { createDiscoverItem(it) } - .orEmpty() - - suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> = - api.searchApi - .discoverMoviesGet(page = page) - .results - ?.map { createDiscoverItem(it) } - .orEmpty() - - suspend fun trending(page: Int = 1): List<DiscoverItem> = - api.searchApi - .discoverTrendingGet(page = page) - .results - ?.map { createDiscoverItem(it) } - .orEmpty() - - suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> = - api.searchApi - .discoverMoviesUpcomingGet(page = page) - .results - ?.map { createDiscoverItem(it) } - .orEmpty() - - suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> = - api.searchApi - .discoverTvUpcomingGet(page = page) - .results - ?.map { createDiscoverItem(it) } - .orEmpty() - - /** - * Get [DiscoverItem]s similar to the JF items such as movies, series, or people - * - * If Seerr integration is not active, this short circuits to return null - * - * @return the discovered items or null if no Seerr server configured - */ - suspend fun similar(item: BaseItem): List<DiscoverItem>? = - if (active.first()) { - item.data.providerIds - ?.get("Tmdb") - ?.toIntOrNull() - ?.let { - when (item.type) { - BaseItemKind.MOVIE -> { - api.moviesApi - .movieMovieIdSimilarGet(movieId = it) - .results - ?.map { createDiscoverItem(it) } - } - - BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { - api.tvApi - .tvTvIdSimilarGet(tvId = it) - .results - ?.map { createDiscoverItem(it) } - } - - BaseItemKind.PERSON -> { - personCredits(it) - } - - else -> { - null - } - } - }.orEmpty() - } else { - 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 - ?.get("Tmdb") - ?.toIntOrNull() - ?.let { tvId -> - api.tvApi.tvTvIdGet(tvId = tvId) - } - } else { - null - } - - suspend fun createImageUrl( - imageType: ImageType, - path: String?, - mediaInfo: MediaInfo?, - backdropHeight: Int = 1080, - backdropWidth: Int = 1920, - ): String? { - if (mediaInfo != null) { - val itemId = mediaInfo.jellyfinId - if (itemId != null) { - return imageUrlService.getItemImageUrl( - itemId = itemId, - imageType = imageType, - ) - } - } - 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 { - 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, - ) - } - } - } - } - 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 - } - } - 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 = - when (item) { - is MovieResult -> createDiscoverItem(item) - is MovieDetails -> createDiscoverItem(item) - is TvResult -> createDiscoverItem(item) - is TvDetails -> createDiscoverItem(item) - is SeerrSearchResult -> createDiscoverItem(item) - is CreditCast -> createDiscoverItem(item) - is CreditCrew -> createDiscoverItem(item) - else -> throw IllegalArgumentException("Unsupported type ${item::class.qualifiedName}") - } - - suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = - DiscoverItem( - id = movie.id, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = - SeerrAvailability.from(movie.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), - backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = - DiscoverItem( - id = movie.id ?: -1, - type = SeerrItemType.MOVIE, - title = movie.title, - subtitle = null, - overview = movie.overview, - availability = - SeerrAvailability.from(movie.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(movie.releaseDate), - posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), - backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), - logoUrl = createImageUrl(ImageType.LOGO, null, movie.mediaInfo), - jellyfinItemId = movie.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = - DiscoverItem( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = - SeerrAvailability.from(tv.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), - backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = - DiscoverItem( - id = tv.id!!, - type = SeerrItemType.TV, - title = tv.name, - subtitle = null, - overview = tv.overview, - availability = - SeerrAvailability.from(tv.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(tv.firstAirDate), - posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), - backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), - logoUrl = createImageUrl(ImageType.LOGO, null, tv.mediaInfo), - jellyfinItemId = tv.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = - DiscoverItem( - id = search.id, - type = SeerrItemType.fromString(search.mediaType), - title = search.title ?: search.name, - subtitle = null, - overview = search.overview, - availability = - SeerrAvailability.from(search.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), - posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), - backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), - logoUrl = createImageUrl(ImageType.LOGO, null, search.mediaInfo), - jellyfinItemId = search.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = - DiscoverItem( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.character, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), - posterUrl = - createImageUrl( - ImageType.PRIMARY, - credit.posterPath ?: credit.profilePath, - credit.mediaInfo, - ), - backDropUrl = - createImageUrl( - ImageType.BACKDROP, - credit.backdropPath, - credit.mediaInfo, - ), - logoUrl = createImageUrl(ImageType.LOGO, null, credit.mediaInfo), - jellyfinItemId = credit.mediaInfo?.jellyfinId, - ) - - suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = - DiscoverItem( - id = credit.id!!, - type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), - title = credit.name ?: credit.title, - subtitle = credit.job, - overview = credit.overview, - availability = - SeerrAvailability.from(credit.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate), - posterUrl = - createImageUrl( - ImageType.PRIMARY, - credit.posterPath ?: credit.profilePath, - credit.mediaInfo, - ), - backDropUrl = - createImageUrl( - ImageType.BACKDROP, - credit.backdropPath, - credit.mediaInfo, - ), - 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 ca6cceb8..f71c902a 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 @@ -9,32 +9,22 @@ import androidx.lifecycle.lifecycleScope 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.ui.collectLatestIn -import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.sessionApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.api.GeneralCommandMessage import org.jellyfin.sdk.model.api.GeneralCommandType import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.UserUpdatedMessage import timber.log.Timber import javax.inject.Inject -/** - * Listens for basic messages from the server such as messages - */ @ActivityScoped class ServerEventListener @Inject @@ -49,7 +39,7 @@ class ServerEventListener init { activity.lifecycle.addObserver(this) - serverRepository.current.collectLatestIn(activity.lifecycleScope) { + serverRepository.current.observe(activity) { Timber.d("New user/server: %s", it) listenJob?.cancel() if (it != null) { @@ -73,71 +63,34 @@ class ServerEventListener ), supportsMediaControl = true, ) - subscribeToWebSocket() + setupListeners() } } } - fun subscribeToWebSocket() { + fun setupListeners() { + serverRepository.currentUser Timber.v("Subscribing to WebSocket") listenJob?.cancel() listenJob = - activity.lifecycleScope.launchDefault { - try { - // Launch multiple listeners, but stop all if one fails - coroutineScope { - api.webSocket - .subscribe<GeneralCommandMessage>() - .onEach { message -> - Timber.v( - "Got GeneralCommandMessage: %s", - message.data?.name, - ) - when (message.data?.name) { - GeneralCommandType.DISPLAY_MESSAGE, - GeneralCommandType.SEND_STRING, - -> { - val header = message.data?.arguments["Header"] - val text = - message.data?.arguments["Text"] - ?: message.data?.arguments["String"] - val toast = - listOfNotNull(header, text) - .joinToString("\n") - if (toast.isNotBlank()) { - showToast(context, toast, Toast.LENGTH_LONG) - } - } - - else -> { - Timber.v( - "Ignoring GeneralCommandMessage: %s", - message.data?.name, - ) - } - } - }.catch { ex -> - Timber.e(ex, "Error in general message websocket subscription") - }.launchIn(this@coroutineScope) - - api.webSocket - .subscribe<UserUpdatedMessage>() - .catch { ex -> - Timber.e(ex, "Error in user updated websocket subscription") - }.collectLatestIn(this@coroutineScope) { msg -> - Timber.v("Got updated user: %s", msg.data?.id) - msg.data?.let { serverRepository.updateUserDto(it) } - } + api.webSocket + .subscribe<GeneralCommandMessage>() + .onEach { message -> + if (message.data?.name in + setOf( + GeneralCommandType.DISPLAY_MESSAGE, + GeneralCommandType.SEND_STRING, + ) + ) { + val header = message.data?.arguments["Header"] + val text = + message.data?.arguments["Text"] ?: message.data?.arguments["String"] + val toast = + listOfNotNull(header, text) + .joinToString("\n") + showToast(context, toast, Toast.LENGTH_LONG) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error in websocket connection") - if (activity.lifecycleScope.isActive) { - subscribeToWebSocket() - } - } - } + }.launchIn(activity.lifecycleScope) } override fun onResume(owner: LifecycleOwner) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt deleted file mode 100644 index 8b51ad63..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.data.model.HomePageSettings -import com.github.damontecres.wholphin.data.model.SeerrPluginSettings -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.util.WholphinDispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.decodeFromStream -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.IOException -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.ApiClientException -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Service for interacting with the companion plugin - */ -@Singleton -class ServerPluginApi - @Inject - constructor( - @param:AuthOkHttpClient private val okHttpClient: OkHttpClient, - private val api: ApiClient, - ) { - private fun createUrl(path: String): String? = - api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" } - - private val json get() = HomeSettingsService.jsonParser - - companion object { - private const val HOME_CONFIG_PATH = "homesettings" - private const val SEERR_CONFIG_PATH = "seerrsettings" - } - - /** - * Check if the plugin is installed on the server - */ - suspend fun checkInstalled(): Boolean = - withContext(WholphinDispatchers.IO) { - val url = createUrl("public") ?: return@withContext false - val request = - Request - .Builder() - .url(url) - .get() - .build() - try { - return@withContext okHttpClient - .newCall(request) - .execute() - .use { it.isSuccessful } - } catch (ex: IOException) { - Timber.w("Could not connect to server plugin: %s", ex.message) - return@withContext false - } - } - - private suspend inline fun <reified T> executeRequest(path: String): T? { - val url = createUrl(path) ?: return null - val request = Request(url.toHttpUrl()) - return executeRequest<T>(request) - } - - @OptIn(ExperimentalSerializationApi::class) - private suspend inline fun <reified T> executeRequest(request: Request): T? = - withContext(WholphinDispatchers.IO) { - okHttpClient.newCall(request).execute().use { res -> - if (res.isSuccessful) { - json.decodeFromStream<T>(res.body.byteStream()) - } else if (res.code == 404) { - Timber.w("Plugin 404: %s", request.url.encodedPath) - null - } else { - throw ApiClientException(res.code.toString() + " " + res.body.string()) - } - } - } - - /** - * Get the default home page settings from the plugin - * - * @return the home page settings or null if no rows are configured or the server isn't installed - */ - suspend fun fetchHomePageSettings(): HomePageSettings? = - executeRequest<HomePageSettings>(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } - - /** - * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured - */ - suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt deleted file mode 100644 index 97369c36..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerReportService.kt +++ /dev/null @@ -1,112 +0,0 @@ -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 -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.clientLogApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemDto -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Send reports to the server such as media info or app logs - */ -@Singleton -class ServerReportService - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, - private val clientInfo: ClientInfo, - private val deviceInfo: DeviceInfo, - private val deviceProfileService: DeviceProfileService, - @param:IoCoroutineScope private val ioScope: CoroutineScope, - ) { - val json = - Json { - encodeDefaults = false - } - - /** - * Fetch the media info and send it to the server - */ - fun sendMediaReportFor(itemId: UUID) { - ioScope.launchIO(ExceptionHandler(autoToast = true)) { - val item = api.userLibraryApi.getItem(itemId = itemId).content - sendMediaReportFor(item) - } - } - - /** - * Send the media report for the given item - */ - suspend fun sendMediaReportFor(item: BaseItemDto) { - val sources = - item.mediaSources ?: api.userLibraryApi - .getItem(itemId = item.id) - .content.mediaSources - val sourcesJson = json.encodeToString(sources) - val appPreferences = userPreferencesService.getCurrent().appPreferences - val serverVersion = serverRepository.currentServer?.serverVersion - val deviceProfile = - deviceProfileService.getOrCreateDeviceProfile(appPreferences, serverVersion) - val deviceProfileJson = json.encodeToString(deviceProfile) - val body = - 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} - """.trimIndent() - } - - 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 deleted file mode 100644 index abe39959..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.compose.runtime.mutableStateListOf -import androidx.navigation3.runtime.NavKey -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.model.JellyfinServer -import kotlinx.serialization.Serializable -import org.acra.ACRA -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Manages navigating for setup - */ -@Singleton -class SetupNavigationManager - @Inject - constructor( - private val navigationManager: NavigationManager, - ) { - var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) - - /** - * Go to the specified [SetupDestination] - */ - fun navigateTo(destination: SetupDestination) { - backStack[0] = destination - log() - } - - private fun log() { - val dest = backStack.lastOrNull().toString() - Timber.i("Current setup destination: %s", dest) - ACRA.errorReporter.putCustomData("setupDestination", dest) - } - } - -@Serializable -sealed interface SetupDestination : NavKey { - @Serializable - data object Loading : SetupDestination - - @Serializable - data object ServerList : SetupDestination - - @Serializable - data class UserList( - val server: JellyfinServer, - ) : SetupDestination - - @Serializable - data class AppContent( - val current: CurrentUser, - ) : SetupDestination -} 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 deleted file mode 100644 index 0b9da9f8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ /dev/null @@ -1,738 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.ActivationFlag -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.RelativeMediaStream -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.data.model.TrackChoiceParentType -import com.github.damontecres.wholphin.data.model.TrackFlag -import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has -import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.data.model.asRelative -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.gt -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.letNotEmpty -import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider -import com.github.damontecres.wholphin.ui.util.ResStringProvider -import com.github.damontecres.wholphin.ui.util.StringProvider -import org.jellyfin.sdk.model.api.BaseItemDto -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.SubtitlePlaybackMode -import org.jellyfin.sdk.model.api.UserConfiguration -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Manage the track choices for media - */ -@Singleton -class StreamChoiceService - @Inject - constructor( - private val serverRepository: ServerRepository, - private val seriesTrackChoiceDao: SeriesTrackChoiceDao, - ) { - private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - - suspend fun saveSeriesTrackChoice( - dto: BaseItemDto, - type: MediaStreamType, - stream: RelativeMediaStream?, - activation: ActivationFlag = ActivationFlag.ACTIVATED, - ) { - val type = - when (stream?.type ?: type) { - MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO - MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE - else -> return - } - val userId = serverRepository.currentUser!!.rowId - val newStc = - buildList { - dto.parentId - ?.let { seasonId -> - SeriesTrackChoice( - userId = userId, - itemId = dto.id, - parentId = seasonId, - type = type, - parentType = TrackChoiceParentType.SEASON, - stream = stream, - activation = activation, - ) - }?.let(::add) - dto.seriesId - ?.let { seriesId -> - SeriesTrackChoice( - userId = userId, - itemId = dto.id, - parentId = seriesId, - type = type, - parentType = TrackChoiceParentType.SERIES, - stream = stream, - activation = activation, - ) - }?.let(::add) - } - Timber.d( - "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s", - dto.id, - dto.seriesId, - dto.parentId, - newStc, - ) - seriesTrackChoiceDao.save(newStc) - } - - suspend fun saveDisabledSeriesTrackChoice( - dto: BaseItemDto, - type: MediaStreamType, - ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) - - suspend fun saveOnlyForcedSeriesTrackChoice( - dto: BaseItemDto, - type: MediaStreamType, - ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) - - /** - * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution - */ - fun chooseSource( - dto: BaseItemDto, - itemPlayback: ItemPlayback?, - ): MediaSourceInfo? = - itemPlayback?.sourceId?.let { dto.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback.sourceId } } - ?: chooseSource(dto.mediaSources) // dto.mediaSources?.firstOrNull() - - /** - * Returns the [MediaSourceInfo] with the highest video resolution - */ - fun chooseSource(sources: List<MediaSourceInfo>?) = - sources?.letNotEmpty { sources -> - val result = - sources.maxByOrNull { s -> - s.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { video -> - (video.width ?: 0) * (video.height ?: 0) - } ?: 0 - } - result - } - - /** - * Returns the audio stream that should play - */ - suspend fun chooseAudioStream( - source: MediaSourceInfo, - item: BaseItemDto, - itemPlayback: ItemPlayback?, - stc: List<SeriesTrackChoice>?, - prefs: UserPreferences, - ): StreamChoiceResult? { - val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) - return source.mediaStreams?.letNotEmpty { streams -> - val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, stc, prefs) - } - } - - /** - * Returns the audio stream that should play - */ - fun chooseAudioStream( - candidates: List<MediaStream>, - itemPlayback: ItemPlayback?, - stc: List<SeriesTrackChoice>, - prefs: UserPreferences, - ): StreamChoiceResult = - if (itemPlayback?.audioIndexEnabled == true) { - candidates.firstOrNull { it.index == itemPlayback.audioIndex } with - StreamChoiceReason.Item( - itemPlayback, - ) - } else if (stc.isNotEmpty()) { - val first = stc.first() - val result = scoreStreams(candidates, first) - if (result == null) { - null with StreamChoiceReason.from(first) - } else if (result.isEmpty() && stc.size > 1) { - // SeriesTrackChoice did not apply to any streams, but there are more options - chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs) - } else if (result.isEmpty()) { - // SeriesTrackChoice did not apply to any streams, so use regular selection logic - Timber.v("No audio STC applied") - chooseAudioStream(candidates, itemPlayback, emptyList(), prefs) - } else { - // Otherwise, use the best scored stream - Timber.v("Using audio STC from %s", first.parentId) - result.first().stream with StreamChoiceReason.from(first, result) - } - } else { - val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) - val stream = - if (audioLanguage.isNotNullOrBlank()) { - val sorted = - candidates.sortedWith(compareBy<MediaStream> { it.language }.thenByDescending { it.channels }) - sorted.firstOrNull { it.language == audioLanguage && it.isDefault } - ?: sorted.firstOrNull { it.language == audioLanguage } - ?: sorted.firstOrNull { it.isDefault } - ?: sorted.firstOrNull() - } else { - candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull() - } - stream with StreamChoiceReason.UserPreferences - } - - /** - * Returns the subtitle stream that should play - */ - suspend fun chooseSubtitleStream( - item: BaseItemDto, - source: MediaSourceInfo, - audioStream: MediaStream?, - itemPlayback: ItemPlayback?, - stc: List<SeriesTrackChoice>?, - prefs: UserPreferences, - ): StreamChoiceResult? = - source.mediaStreams?.letNotEmpty { streams -> - val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } - val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) - chooseSubtitleStream( - audioStream?.language, - candidates, - itemPlayback, - stc, - prefs, - ) - } - - /** - * Resolves ONLY_FORCED to an actual subtitle track index. - * Returns the original index if not ONLY_FORCED or DISABLED. - */ - suspend fun resolveSubtitleIndex( - source: MediaSourceInfo, - audioStreamIndex: Int?, - item: BaseItemDto, - subtitleIndex: Int, - prefs: UserPreferences, - ): Int? = - if (subtitleIndex != TrackIndex.ONLY_FORCED) { - subtitleIndex - } else { - val audioStream = - source.mediaStreams?.firstOrNull { - it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex - } - val itemPlayback = - ItemPlayback( - userId = serverRepository.currentUser!!.rowId, - itemId = item.id, - subtitleIndex = TrackIndex.ONLY_FORCED, - ) - chooseSubtitleStream( - item = item, - source = source, - audioStream = audioStream, - itemPlayback = itemPlayback, - stc = null, - prefs = prefs, - )?.stream?.index - } - - /** - * Returns the subtitle stream that should play - */ - fun chooseSubtitleStream( - audioStreamLang: String?, - candidates: List<MediaStream>, - itemPlayback: ItemPlayback?, - stc: List<SeriesTrackChoice>, - prefs: UserPreferences, - ): StreamChoiceResult { - if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) { - if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) { - return null with StreamChoiceReason.Item(itemPlayback) - } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) { - val subtitleLanguage = - getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - // Client-side manual override: User selected "Only Forced" in player menu - return findForcedTrack( - candidates, - subtitleLanguage, - audioStreamLang, - ) with StreamChoiceReason.Item(itemPlayback) - } else { - return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with - StreamChoiceReason.Item(itemPlayback) - } - } else if (stc.isNotEmpty()) { - val first = stc.first() - return when (first.activation) { - ActivationFlag.DISABLED -> { - null with StreamChoiceReason.from(first) - } - - ActivationFlag.ONLY_FORCED -> { - val subtitleLanguage = - getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - val stream = - findForcedTrack( - candidates, - subtitleLanguage, - audioStreamLang, - ) - if (stream != null) { - stream with StreamChoiceReason.from(first) - } else { - handleSubtitleStreamChoiceResult( - result = emptyList(), - audioStreamLang = audioStreamLang, - candidates = candidates, - itemPlayback = itemPlayback, - stc = stc, - prefs = prefs, - ) - } - } - - ActivationFlag.ACTIVATED -> { - val result = - scoreStreams(candidates, first) - ?: throw IllegalStateException("scoreStreams should not return null for activated") - handleSubtitleStreamChoiceResult( - result = result, - audioStreamLang = audioStreamLang, - candidates = candidates, - itemPlayback = itemPlayback, - stc = stc, - prefs = prefs, - ) - } - } - } else { - val subtitleLanguage = - getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - val subtitleMode = - 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( - compareByDescending<MediaStream> { it.isExternal } - .thenByDescending { it.isDefault } - .thenByDescending { - !it.isForced && it.language.equals(subtitleLanguage, true) - }.thenByDescending { - it.isForced && it.language.equals(subtitleLanguage, true) - }.thenByDescending { it.isForced && it.language.isUnknown } - .thenByDescending { it.isForced }, - ) - val stream = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { - // Prefer non-forced first - !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) - } ?: candidates.firstOrNull { - it.language.equalsLangOrUnknown(subtitleLanguage) - } - } else { - candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } - ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } - } else { - candidates.firstOrNull { it.isForced } - } - } - - SubtitlePlaybackMode.SMART -> { - if (subtitleLanguage.isNotNullOrBlank()) { - val audioLanguage = userConfig?.audioLanguagePreference - if ( - // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different - (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || - // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio - (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) - ) { - candidates.firstOrNull { it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.language.isUnknown } - } else { - // Otherwise, show forced subtitles in preferred lang - candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } - } - } else { - candidates.firstOrNull { it.isDefault } - } - } - - SubtitlePlaybackMode.DEFAULT -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } - ?: candidates.firstOrNull { it.isDefault || it.isForced } - } else { - candidates.firstOrNull { it.isDefault || it.isForced } - } - } - - SubtitlePlaybackMode.NONE -> { - null - } - } - return stream with StreamChoiceReason.UserPreferences - } - } - - /** - * Recursively calls [chooseSubtitleStream] based on [result] and [stc] - */ - private fun handleSubtitleStreamChoiceResult( - result: List<ScoredMediaStream>, - audioStreamLang: String?, - candidates: List<MediaStream>, - itemPlayback: ItemPlayback?, - stc: List<SeriesTrackChoice>, - prefs: UserPreferences, - ): StreamChoiceResult { - val first = stc.first() - return if (result.isEmpty() && stc.size > 1) { - // SeriesTrackChoice did not apply to any streams, but there are more options - chooseSubtitleStream( - audioStreamLang, - candidates, - itemPlayback, - stc.subList(1, stc.size), - prefs, - ) - } else if (result.isEmpty()) { - // SeriesTrackChoice did not apply to any streams, so use regular selection logic - Timber.v("No subtitle STC applied") - chooseSubtitleStream( - audioStreamLang, - candidates, - itemPlayback, - emptyList(), - prefs, - ) - } else { - // Otherwise, use the best scored stream - Timber.v("Using subtitle STC from %s", first.parentId) - result.first().stream with StreamChoiceReason.from(first, result) - } - } - - /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ - private fun findForcedTrack( - candidates: List<MediaStream>, - subtitleLanguage: String?, - audioLanguage: String?, - ): MediaStream? { - // 1. User's preferred subtitle language - if (subtitleLanguage != null) { - candidates - .firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) } - ?.let { return it } - } - // 2. Audio language (for sign-subtitles if no preference match) - if (audioLanguage != null) { - candidates - .firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) } - ?.let { return it } - } - // 3. Unknown language forced track - return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } - } - - suspend fun getSeriesTrackChoices( - seriesId: UUID?, - seasonId: UUID?, - type: SeriesTrackChoiceType, - ): List<SeriesTrackChoice> { - val userId = serverRepository.currentUser?.rowId ?: return emptyList() - return when { - seriesId != null && seasonId != null -> { - seriesTrackChoiceDao.get( - userId = userId, - seasonId = seasonId, - seriesId = seriesId, - type = type, - ) - } - - seriesId != null -> { - seriesTrackChoiceDao.getBySeriesId( - userId = userId, - seriesId = seriesId, - type = type, - ) - } - - seasonId != null -> { - seriesTrackChoiceDao.getBySeasonId( - userId = userId, - seasonId = seasonId, - type = type, - ) - } - - else -> { - emptyList() - } - } - } - - suspend fun getSeriesTrackChoices( - item: BaseItemDto, - type: SeriesTrackChoiceType, - ): List<SeriesTrackChoice> = getSeriesTrackChoices(item.seriesId, item.parentId, type) - - suspend fun getSeriesTrackChoices( - item: BaseItemDto, - type: MediaStreamType, - ): List<SeriesTrackChoice> = - getSeriesTrackChoices( - seriesId = item.seriesId, - seasonId = item.parentId, - type = - when (type) { - MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO - MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE - else -> return emptyList() - }, - ) - } - -private val String?.isUnknown: Boolean - get() = - this.isNullOrBlank() || - this.equals("unknown", true) || - this.equals("und", true) || - this.equals("undetermined", true) || - this.equals("mul", true) || - this.equals("zxx", true) - -private val String?.isNotUnknown: Boolean - get() = !this.isUnknown - -private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown - -private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true) - -/** Returns true if the track is forced (via metadata flag or title patterns). */ -fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track) - -fun isSigns(track: MediaStream): Boolean { - val title = track.title ?: track.displayTitle ?: return false - return title.contains("forced", ignoreCase = true) || - title.contains("signs", ignoreCase = true) || - title.contains("songs", ignoreCase = true) -} - -/** - * 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() } -} - -/** - * Scores streams based the user's explicit [SeriesTrackChoice] - * - * @return sorted list of preferred streams or null if stream should be disabled - */ -fun scoreStreams( - streams: List<MediaStream>, - choice: SeriesTrackChoice, -): List<ScoredMediaStream>? { - if (choice.activation == ActivationFlag.DISABLED) { - return null - } - val streams = - streams.filter { - if ( - (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) && - !isForcedOrSigns(it) - ) { - // Filter out non-forced tracks if the user only wants forced - return@filter false - } - if (choice.language.isNotUnknown && - !choice.language.equalsLangExact(it.language) - ) { - // Filter out languages that do not match - return@filter false - } - // Filter by type - choice.type.mediaStreamType == it.type - } - val scored = - streams - .asRelative() - .map { s -> - var score = 0 - - // Prefer exact language match over unknown - if (choice.language.equalsLangExact(s.language)) score += 10_000 - - if (s.isForced && choice.has(TrackFlag.FORCED)) { - score += 1_000 - } - if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) { - score += 500 - } - if (s.isDefault && choice.has(TrackFlag.DEFAULT)) { - score += 500 - } - - if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) { - score += 500 - } else if (choice.has(TrackFlag.SDH) && s.isForced) { - // If user wants SDH, prefer non-forced "regular" over forced - score -= 10 - } - - if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) { - score += 100 - } - - if (s.relativeIndex == choice.relativeTrackIndex) { - score += 100 - } - if (s.stream.title == choice.title) { - score += 10 - } - - if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) { - score += 100 - } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) { - score += 50 - } - - if (s.isDefault) { - // All else being equal, prefer the default track - score += 1 - } - ScoredMediaStream(score, s.stream) - }.sortedByDescending { it.score } - return scored -} - -data class StreamChoiceResult( - val stream: MediaStream?, - val reason: StreamChoiceReason, -) - -data class ScoredMediaStream( - val score: Int, - val stream: MediaStream, -) - -private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason) - -/** - * Why a particular [MediaStream] was chosen - */ -sealed interface StreamChoiceReason { - val description: StringProvider - val scores: List<ScoredMediaStream> get() = emptyList() - - data object Unknown : StreamChoiceReason { - override val description: StringProvider = ResStringProvider(R.string.unknown) - } - - data object UserPreferences : StreamChoiceReason { - override val description: StringProvider = - ResStringProvider(R.string.profile_specific_settings) - } - - data class Series( - val stc: SeriesTrackChoice, - override val scores: List<ScoredMediaStream>, - ) : StreamChoiceReason { - override val description: StringProvider = - ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER) - } - - data class Season( - val stc: SeriesTrackChoice, - override val scores: List<ScoredMediaStream>, - ) : StreamChoiceReason { - override val description: StringProvider = - ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER) - } - - data class Item( - val itemPlayback: ItemPlayback, - ) : StreamChoiceReason { - override val description: StringProvider = - ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER) - } - - companion object { - fun from( - stc: SeriesTrackChoice, - scores: List<ScoredMediaStream> = emptyList(), - ) = when (stc.parentType) { - TrackChoiceParentType.SERIES -> Series(stc, scores) - TrackChoiceParentType.SEASON -> Season(stc, scores) - TrackChoiceParentType.DEPRECATED -> Unknown - TrackChoiceParentType.UNKNOWN -> Unknown - } - - private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) } - private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) } - private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt deleted file mode 100644 index bed98436..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.work.Constraints -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.workDataOf -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -sealed interface SuggestionsResource { - data object Loading : SuggestionsResource - - data class Success( - val items: List<BaseItem>, - ) : SuggestionsResource - - data object Empty : SuggestionsResource -} - -@Singleton -class SuggestionService - @Inject - constructor( - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val cache: SuggestionsCache, - private val workManager: WorkManager, - ) { - @OptIn(ExperimentalCoroutinesApi::class) - fun getSuggestionsFlow( - parentId: UUID, - itemKind: BaseItemKind, - ): Flow<SuggestionsResource> { - return serverRepository.currentUserFlow - .flatMapLatest { user -> - val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) - val cachedSuggestions = cache.get(userId, parentId, itemKind) - if (cachedSuggestions == null) { - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, itemKind) - Timber.d( - "No cached suggestions for parent=%s kind=%s; scheduling one-time suggestions refresh", - parentId, - itemKind, - ) - enqueueSuggestionsWork(user, parentId, itemKind, workName) - workManager - .getWorkInfosForUniqueWorkFlow(workName) - .map { workInfos -> - cache.get(userId, parentId, itemKind)?.let { - Timber.d( - "Loaded cached suggestions after work update for parent=%s kind=%s count=%d", - parentId, - itemKind, - it.ids.size, - ) - return@map it.toResource(parentId, itemKind) - } - val states = workInfos.map { it.state }.distinct() - Timber.v( - "Observed suggestions work states for parent=%s kind=%s states=%s", - parentId, - itemKind, - states, - ) - val isActive = - workInfos.any { - it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED - } - if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty - } - } else { - Timber.v( - "Using cached suggestions for parent=%s kind=%s count=%d", - parentId, - itemKind, - cachedSuggestions.ids.size, - ) - flow { - emit(cachedSuggestions.toResource(parentId, itemKind)) - } - } - } - } - - private fun enqueueSuggestionsWork( - user: JellyfinUser, - parentId: UUID, - itemKind: BaseItemKind, - workName: String, - ) { - val constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - val request = - OneTimeWorkRequestBuilder<SuggestionsWorker>() - .setConstraints(constraints) - .setInputData( - workDataOf( - SuggestionsWorker.PARAM_USER_ID to user.id.toString(), - SuggestionsWorker.PARAM_SERVER_ID to user.serverId.toString(), - SuggestionsWorker.PARAM_PARENT_ID to parentId.toString(), - SuggestionsWorker.PARAM_ITEM_KIND to itemKind.serialName, - ), - ).addTag(SuggestionsWorker.getOnDemandWorkTag(user.id, parentId, itemKind)) - .build() - - workManager.enqueueUniqueWork( - workName, - ExistingWorkPolicy.REPLACE, - request, - ) - Timber.d( - "Enqueued on-demand suggestions for parent=%s kind=%s", - parentId, - itemKind, - ) - } - - private suspend fun CachedSuggestions.toResource( - parentId: UUID, - itemKind: BaseItemKind, - ): SuggestionsResource { - if (ids.isEmpty()) { - Timber.d("Cached suggestions are empty for parent=%s kind=%s", parentId, itemKind) - return SuggestionsResource.Empty - } - return try { - SuggestionsResource.Success(fetchItemsByIds(ids, itemKind)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "Failed to fetch items") - SuggestionsResource.Empty - } - } - - private suspend fun fetchItemsByIds( - ids: List<UUID>, - itemKind: BaseItemKind, - ): List<BaseItem> { - val isSeries = itemKind == BaseItemKind.SERIES - val request = - GetItemsRequest( - ids = ids, - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW), - ) - return GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem.from(it, api, isSeries) } - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt deleted file mode 100644 index eedbffc5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ /dev/null @@ -1,108 +0,0 @@ -@file:UseSerializers(UUIDSerializer::class) - -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.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromStream -import kotlinx.serialization.json.encodeToStream -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import timber.log.Timber -import java.io.File -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -@Serializable -data class CachedSuggestions( - val ids: List<UUID>, -) - -@Singleton -class SuggestionsCache - @Inject - constructor( - @param:ApplicationContext private val context: Context, - ) { - private val cacheDir: File - get() = File(context.cacheDir, "suggestions") - private val json = Json { ignoreUnknownKeys = true } - private val mutex = Mutex() - - private val memoryCache = - InMemoryKache<String, CachedSuggestions>(maxSize = 8) { - creationScope = CoroutineScope(WholphinDispatchers.IO) - } - - private fun cacheKey( - userId: UUID, - libraryId: UUID, - itemKind: BaseItemKind, - ) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}" - - @OptIn(ExperimentalSerializationApi::class) - suspend fun get( - userId: UUID, - libraryId: UUID, - itemKind: BaseItemKind, - ): CachedSuggestions? { - val key = cacheKey(userId, libraryId, itemKind) - return memoryCache.getOrPut(key) { - mutex.withLock { - try { - val cacheFile = File(cacheDir, "$key.json") - if (!cacheFile.exists()) { - return@withLock null - } - - cacheFile.inputStream().use { - json.decodeFromStream<CachedSuggestions>(it) - } - } catch (ex: Exception) { - Timber.e(ex, "Exception reading from disk cache") - null - } - } - } - } - - @OptIn(ExperimentalSerializationApi::class) - suspend fun put( - userId: UUID, - libraryId: UUID, - itemKind: BaseItemKind, - ids: List<UUID>, - ) { - val key = cacheKey(userId, libraryId, itemKind) - val suggestions = CachedSuggestions(ids) - memoryCache.put(key, suggestions) - try { - cacheDir.mkdirs() - mutex.withLock { - File(cacheDir, "$key.json").outputStream().use { - json.encodeToStream(suggestions, it) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception writing to disk cache") - } - } - } - -fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L - -fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty() - -suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt deleted file mode 100644 index da6ffef9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.workDataOf -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.util.ExceptionHandler -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.random.Random -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration - -@ActivityScoped -class SuggestionsSchedulerService - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val workManager: WorkManager, - @param:IoDispatcher private val dispatcher: CoroutineDispatcher, - ) { - private val activity = - (context as? AppCompatActivity) - ?: throw IllegalStateException( - "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", - ) - - internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } - - init { - activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { - serverRepository.current.collect { user -> - Timber.v("New user %s", user?.user?.id) - if (user == null) { - workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) - } else { - activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { - scheduleWork(user.user.id, user.server.id) - } - } - } - } - } - - private suspend fun scheduleWork( - userId: UUID, - serverId: UUID, - ) { - val workInfos = - withContext(dispatcher) { - workManager - .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) - .get() - } - val activeWork = - workInfos.firstOrNull { - !it.state.isFinished - } - val scheduledUserId = - activeWork - ?.tags - ?.firstOrNull { it.startsWith("user:") } - ?.removePrefix("user:") - - val isAlreadyScheduledForUser = scheduledUserId == userId.toString() - if (isAlreadyScheduledForUser) { - Timber.d("SuggestionsWorker already scheduled for user %s", userId) - return - } - - val constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val inputData = - workDataOf( - SuggestionsWorker.PARAM_USER_ID to userId.toString(), - SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), - ) - - val periodicWorkRequestBuilder = - PeriodicWorkRequestBuilder<SuggestionsWorker>( - repeatInterval = 12.hours.toJavaDuration(), - flexTimeInterval = 1.hours.toJavaDuration(), - ).setConstraints(constraints) - .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - 15.minutes.toJavaDuration(), - ).setInputData(inputData) - .addTag("user:$userId") - - val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) - periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) - - Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") - - workManager.enqueueUniquePeriodicWork( - uniqueWorkName = SuggestionsWorker.WORK_NAME, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = periodicWorkRequestBuilder.build(), - ) - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt deleted file mode 100644 index e0dd472f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ /dev/null @@ -1,373 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.github.damontecres.wholphin.data.RestoredSession -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.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.ApiClientException -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID -import kotlin.coroutines.CoroutineContext - -private val BaseItemDto.relevantId: UUID get() = seriesId ?: id - -@HiltWorker -class SuggestionsWorker - @AssistedInject - constructor( - @Assisted private val context: Context, - @Assisted workerParams: WorkerParameters, - private val serverRepository: ServerRepository, - private val preferences: DataStore<AppPreferences>, - private val api: ApiClient, - private val cache: SuggestionsCache, - ) : CoroutineWorker(context, workerParams) { - override suspend fun doWork(): Result { - val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() - val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() - val requestedParentId = inputData.getString(PARAM_PARENT_ID)?.toUUIDOrNull() - val requestedItemKind = - inputData - .getString(PARAM_ITEM_KIND) - ?.let { runCatching { BaseItemKind.fromName(it) }.getOrNull() } - val mode = if (requestedParentId == null && requestedItemKind == null) "periodic" else "on-demand" - Timber.d("Starting SuggestionsWorker mode=%s", mode) - if ((requestedParentId == null) != (requestedItemKind == null)) { - Timber.w("Invalid on-demand suggestions input parent=%s kind=%s", requestedParentId, requestedItemKind) - return Result.failure() - } - - if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { - Timber.w("No user found during run") - return Result.failure() - } - } - - try { - val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val itemsPerRow = - prefs.homePagePreferences.maxItemsPerRow - .takeIf { it > 0 } - ?: AppPreference.HomePageItems.defaultValue.toInt() - - val context = WholphinDispatchers.IO.limitedParallelism(2, "fetchSuggestions") - if (requestedParentId != null && requestedItemKind != null) { - Timber.d( - "Fetching on-demand suggestions for parent=%s kind=%s", - requestedParentId, - requestedItemKind, - ) - fetchAndCacheSuggestions( - context, - requestedParentId, - userId, - requestedItemKind, - itemsPerRow, - ) - Timber.d( - "Completed on-demand suggestions for parent=%s kind=%s", - requestedParentId, - requestedItemKind, - ) - return Result.success() - } - - val views = - api.userViewsApi - .getUserViews(userId = userId) - .content.items - .orEmpty() - if (views.isEmpty()) { - Timber.d("No user views found for periodic suggestions refresh") - return Result.success() - } - val supportedViews = - views.mapNotNull { view -> - getTypeForCollection(view.collectionType)?.let { - SuggestionsView(view.id, it) - } - } - val skippedCount = views.size - supportedViews.size - Timber.d( - "Refreshing periodic suggestions for %d supported views; skipped=%d", - supportedViews.size, - skippedCount, - ) - val results = - supervisorScope { - supportedViews - .map { view -> - async(WholphinDispatchers.IO) { - runCatching { - Timber.v("Fetching suggestions for parent=%s kind=%s", view.id, view.itemKind) - fetchAndCacheSuggestions( - context, - view.id, - userId, - view.itemKind, - itemsPerRow, - ) - ensureActive() - }.onFailure { e -> - Timber.e( - e, - "Failed to fetch suggestions for parent=%s kind=%s", - view.id, - view.itemKind, - ) - } - } - }.awaitAll() - } - val successCount = results.count { it.isSuccess } - val failureCount = results.count { it.isFailure } - if (failureCount > 0 && successCount == 0) { - Timber.w("All attempts failed ($failureCount views), scheduling retry") - return Result.retry() - } - Timber.d("Completed with $successCount successes and $failureCount failures") - return Result.success() - } catch (ex: ApiClientException) { - Timber.w(ex, "SuggestionsWorker ApiClientException, mode=%s", mode) - if (mode == "on-demand") { - return Result.failure() - } - return Result.retry() - } catch (e: Exception) { - Timber.e(e, "SuggestionsWorker failed") - return Result.failure() - } - } - - private suspend fun fetchAndCacheSuggestions( - coroutineContext: CoroutineContext, - parentId: UUID, - userId: UUID, - itemKind: BaseItemKind, - itemsPerRow: Int, - ) { - val suggestions = - fetchSuggestions( - coroutineContext, - parentId, - userId, - itemKind, - itemsPerRow, - ) - val newIds = suggestions.map { it.id } - val cachedIds = cache.get(userId, parentId, itemKind)?.ids - if (cachedIds == newIds) { - Timber.v( - "Suggestions unchanged for parent=%s kind=%s count=%d; skipping cache write", - parentId, - itemKind, - newIds.size, - ) - return - } - cache.put( - userId, - parentId, - itemKind, - newIds, - ) - if (newIds.isEmpty()) { - Timber.d("Cached empty suggestions for parent=%s kind=%s", parentId, itemKind) - } else { - Timber.d( - "Cached %d suggestions for parent=%s kind=%s", - newIds.size, - parentId, - itemKind, - ) - } - } - - private suspend fun fetchSuggestions( - coroutineContext: CoroutineContext, - parentId: UUID, - userId: UUID, - itemKind: BaseItemKind, - itemsPerRow: Int, - ): List<BaseItemDto> = - coroutineScope { - val isSeries = itemKind == BaseItemKind.SERIES - val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind - - val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1) - val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) - val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) - - val historyDeferred = - async(coroutineContext) { - fetchItems( - parentId = parentId, - userId = userId, - itemKind = historyItemType, - sortBy = ItemSortBy.DATE_PLAYED, - isPlayed = true, - limit = 20, - extraFields = listOf(ItemFields.GENRES), - ).distinctBy { it.relevantId }.take(3) - } - - val seedItems = historyDeferred.await() - - val allGenreIds = - seedItems - .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } - .distinct() - - val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } - - val contextualDeferred = - async(coroutineContext) { - if (allGenreIds.isEmpty()) { - emptyList() - } else { - fetchItems( - parentId = parentId, - userId = userId, - itemKind = itemKind, - sortBy = ItemSortBy.RANDOM, - isPlayed = false, - limit = contextualLimit, - genreIds = allGenreIds, - excludeItemIds = excludeIds.toList(), - ) - } - } - - val randomDeferred = - async(coroutineContext) { - fetchItems( - parentId = parentId, - userId = userId, - itemKind = itemKind, - sortBy = ItemSortBy.RANDOM, - isPlayed = false, - limit = randomLimit, - ) - } - - val freshDeferred = - async(coroutineContext) { - fetchItems( - parentId = parentId, - userId = userId, - itemKind = itemKind, - sortBy = ItemSortBy.DATE_CREATED, - sortOrder = SortOrder.DESCENDING, - isPlayed = false, - limit = freshLimit, - ) - } - withContext(WholphinDispatchers.Default) { - val contextual = contextualDeferred.await() - val random = randomDeferred.await() - val fresh = freshDeferred.await() - - (contextual + fresh + random) - .asSequence() - .distinctBy { it.id } - .filterNot { excludeIds.contains(it.relevantId) } - .toList() - .shuffled() - .take(itemsPerRow) - } - } - - private suspend fun fetchItems( - parentId: UUID, - userId: UUID, - itemKind: BaseItemKind, - sortBy: ItemSortBy, - isPlayed: Boolean, - limit: Int, - sortOrder: SortOrder? = null, - genreIds: List<UUID>? = null, - excludeItemIds: List<UUID>? = null, - extraFields: List<ItemFields> = emptyList(), - ): List<BaseItemDto> { - val request = - GetItemsRequest( - parentId = parentId, - userId = userId, - fields = extraFields, - includeItemTypes = listOf(itemKind), - genreIds = genreIds, - recursive = true, - isPlayed = isPlayed, - excludeItemIds = excludeItemIds, - sortBy = listOf(sortBy), - sortOrder = sortOrder?.let { listOf(it) }, - limit = limit, - enableTotalRecordCount = false, - imageTypeLimit = 0, - ) - return GetItemsRequestHandler - .execute(api, request) - .content.items - .orEmpty() - } - - companion object { - const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" - const val PARAM_USER_ID = "userId" - const val PARAM_SERVER_ID = "serverId" - const val PARAM_PARENT_ID = "parentId" - const val PARAM_ITEM_KIND = "itemKind" - - fun getOnDemandWorkName( - userId: UUID, - parentId: UUID, - itemKind: BaseItemKind, - ): String = "$WORK_NAME.onDemand.$userId.$parentId.${itemKind.serialName}" - - fun getOnDemandWorkTag( - userId: UUID, - parentId: UUID, - itemKind: BaseItemKind, - ): String = "suggestions:$userId:$parentId:${itemKind.serialName}" - - fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = - when (collectionType) { - CollectionType.MOVIES -> BaseItemKind.MOVIE - CollectionType.TVSHOWS -> BaseItemKind.SERIES - else -> null - } - } - - private data class SuggestionsView( - val id: UUID, - val itemKind: BaseItemKind, - ) - } 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 036ca505..f43df5cc 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 @@ -2,30 +2,17 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.OptIn -import androidx.datastore.core.DataStore import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import com.github.damontecres.wholphin.preferences.AppPreferences 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.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.api.client.ApiClient @@ -47,12 +34,7 @@ class ThemeSongPlayer @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, private val api: ApiClient, - @param:IoCoroutineScope private val scope: CoroutineScope, - private val preferences: DataStore<AppPreferences>, ) { - private val mutex = Mutex() - private val state = MutableStateFlow(ThemeSongState()) - private val player: Player by lazy { ExoPlayer .Builder(context) @@ -63,86 +45,62 @@ class ThemeSongPlayer ).build() } - fun playThemeFor(itemId: UUID) { - scope.launch { - mutex.withLock { - state.value.job?.join() - val volumeLevel = - when ( - preferences.data - .first() - .interfacePreferences.playThemeSongs - ) { - ThemeSongVolume.UNRECOGNIZED, - ThemeSongVolume.DISABLED, - -> return@withLock - - ThemeSongVolume.LOWEST -> .05f - - ThemeSongVolume.LOW -> .1f - - ThemeSongVolume.MEDIUM -> .25f - - ThemeSongVolume.HIGH -> .5f - - ThemeSongVolume.HIGHEST -> 75f - } - val job = - scope.launchIO { - val themeSongs by api.libraryApi.getThemeSongs(itemId) - themeSongs.items.randomOrNull()?.let { theme -> - val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - theme.id, - container = - listOf( - Codec.Audio.OPUS, - Codec.Audio.MP3, - Codec.Audio.AAC, - Codec.Audio.FLAC, - ), - ) - withContext(WholphinDispatchers.Main) { - player.apply { - stop() - volume = volumeLevel - setMediaItem(MediaItem.fromUri(url)) - prepare() - play() - } - } - } - } - state.update { - it.copy( - itemId = itemId, - job = job, - ) - } + suspend fun playThemeFor( + itemId: UUID, + volume: ThemeSongVolume, + ): Boolean = + withContext(Dispatchers.IO) { + if (volume == ThemeSongVolume.DISABLED || volume == ThemeSongVolume.UNRECOGNIZED) { + return@withContext false } + val themeSongs by api.libraryApi.getThemeSongs(itemId) + return@withContext themeSongs.items.randomOrNull()?.let { theme -> + val url = + api.universalAudioApi.getUniversalAudioStreamUrl( + theme.id, + container = + listOf( + Codec.Audio.OPUS, + Codec.Audio.MP3, + Codec.Audio.AAC, + Codec.Audio.FLAC, + ), + ) + Timber.v("Found theme song for $itemId") + withContext(Dispatchers.Main) { + play(volume, url) + } + true + } ?: false + } + + private fun play( + volumeLevel: ThemeSongVolume, + url: String, + ) { + val volumeLevel = + when (volumeLevel) { + ThemeSongVolume.UNRECOGNIZED, + ThemeSongVolume.DISABLED, + -> return + + ThemeSongVolume.LOWEST -> .05f + ThemeSongVolume.LOW -> .1f + ThemeSongVolume.MEDIUM -> .25f + ThemeSongVolume.HIGH -> .5f + ThemeSongVolume.HIGHEST -> 75f + } + player.apply { + stop() + volume = volumeLevel + setMediaItem(MediaItem.fromUri(url)) + prepare() + play() } } fun stop() { - scope.launch { - mutex.withLock { - state.value.job?.cancelAndJoin() - withContext(WholphinDispatchers.Main) { - Timber.v("Stopping theme song") - player.stop() - } - state.update { - it.copy( - itemId = null, - job = null, - ) - } - } - } + Timber.v("Stopping theme song") + player.stop() } } - -data class ThemeSongState( - val itemId: UUID? = null, - val job: Job? = null, -) 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 22df106b..42f56b04 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 @@ -15,9 +15,6 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import javax.inject.Inject import javax.inject.Singleton -/** - * Gets trailers for media - */ @Singleton class TrailerService @Inject @@ -25,53 +22,31 @@ class TrailerService @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { - fun getRemoteTrailers(item: BaseItem): List<Trailer> = - item.data.remoteTrailers - ?.mapNotNull { t -> - t.url?.let { url -> - val name = - t.name - // TODO would be nice to clean up the trailer name + suspend fun getTrailers(item: BaseItem): List<Trailer> { + val remoteTrailers = + item.data.remoteTrailers + ?.mapNotNull { t -> + t.url?.let { url -> + val name = + t.name + // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.resources.getQuantityString(R.plurals.trailers, 1) - val subtitle = - when (url.toUri().host) { - "youtube.com", "www.youtube.com" -> "YouTube" - else -> null - } - RemoteTrailer(name, url, subtitle) - } - }.orEmpty() - .sortedWith( - compareBy( - { - // Try to show official trailers first & teasers last - when { - it.name.contains("Official Trailer", true) -> 0 - it.name.contains("Official Theatrical Trailer", true) -> 0 - it.name.contains("Teaser", true) -> 10 - it.name.contains("Trailer", true) -> 1 - else -> 5 - } - }, - { - it.name - }, - ), - ) - - suspend fun getLocalTrailers(item: BaseItem): List<Trailer> { - val localTrailerCount = item.data.localTrailerCount ?: return emptyList() + ?: context.getString(R.string.trailer) + RemoteTrailer(name, url) + } + }.orEmpty() + .sortedBy { it.name } + val localTrailerCount = item.data.localTrailerCount ?: 0 val localTrailers = if (localTrailerCount > 0) { api.userLibraryApi.getLocalTrailers(item.id).content.map { - LocalTrailer(BaseItem.from(it, api)) + LocalTrailer(BaseItem.Companion.from(it, api)) } } else { listOf() } - return localTrailers + return localTrailers + remoteTrailers } companion object { @@ -87,14 +62,14 @@ class TrailerService navigateTo: (Destination) -> Unit, ) { when (trailer) { - is LocalTrailer -> { + is LocalTrailer -> navigateTo.invoke( Destination.Playback( itemId = trailer.baseItem.id, + item = trailer.baseItem, positionMs = 0L, ), ) - } is RemoteTrailer -> { val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri()) 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 706285f2..52758a38 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,7 +1,6 @@ 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 @@ -15,15 +14,13 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.services.UpdateChecker.Companion.ASSET_NAME 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 @@ -36,6 +33,7 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.internal.headersContentLength import timber.log.Timber import java.io.File import java.io.InputStream @@ -46,9 +44,6 @@ import javax.inject.Singleton import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds -/** - * Checks if an app update is available - */ @Singleton class UpdateChecker @Inject @@ -57,68 +52,19 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - const val ASSET_NAME = "Wolfskin" - const val APK_NAME = "$ASSET_NAME.apk" + // TODO apk names + private const val ASSET_NAME = "Wholphin" + private const val APK_NAME = "$ASSET_NAME.apk" 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 - - private const val RELEASES_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases?limit=50" - - /** - * Find the release in a Forgejo release list whose name parses to the given version - */ - fun findReleaseByVersion( - releases: JsonArray, - version: Version, - ): JsonObject? = - releases - .firstOrNull { release -> - val candidate = Version.tryFromString(release.jsonObject["name"]?.jsonPrimitive?.contentOrNull) - candidate != null && !candidate.isGreaterThan(version) && !version.isGreaterThan(candidate) - }?.jsonObject - - /** - * Parse a single release object (Forgejo or GitHub shape) into a [Release], or null if its name is not a version - */ - fun parseRelease( - json: JsonObject, - debug: Boolean, - supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), - ): Release? { - val name = json["name"]?.jsonPrimitive?.contentOrNull - val version = Version.tryFromString(name) - if (version == null) { - Timber.w("Update version parsing failed. name=$name") - return null - } - val publishedAt = json["published_at"]?.jsonPrimitive?.contentOrNull - val body = json["body"]?.jsonPrimitive?.contentOrNull - val downloadUrl = - json["assets"]?.jsonArray?.let { assets -> getDownloadUrl(assets, debug, supportedABIs) } - Timber.v("version=$version, downloadUrl=$downloadUrl") - val notes = - if (body.isNotNullOrBlank()) { - NOTE_REGEX - .findAll(body) - .map { m -> - m.groupValues[1] - }.toList() - } else { - emptyList() - } - return Release(version, downloadUrl, publishedAt, body, notes) - } + val ACTIVE = true } - /** - * If the app hasn't recently checked, check for any updates and if there is one, show a toast message - * - * This is safe to call many times because it will only show the toast at most once every 12 hours - */ suspend fun maybeShowUpdateToast( updateUrl: String, showNegativeToast: Boolean = false, @@ -166,74 +112,94 @@ class UpdateChecker } } - /** - * Get the currently installed version - */ fun getInstalledVersion(): Version { val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0) - return Version.fromString(pkgInfo.versionName!!) + return Version.Companion.fromString(pkgInfo.versionName!!) } - /** - * Get the release whose name parses to the given version. - * - * Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`, - * so this searches the release list instead of fetching a tag. - */ - suspend fun getRelease(version: Version): Release? { - val request = - Request - .Builder() - .url(RELEASES_URL) - .get() - .build() - return withContext(WholphinDispatchers.IO) { - okHttpClient.newCall(request).execute().use { - if (it.isSuccessful) { - val releases = Json.parseToJsonElement(it.body.string()).jsonArray - findReleaseByVersion(releases, version)?.let { json -> parseRelease(json, BuildConfig.DEBUG) } - } else { - Timber.w("Release lookup failed ${it.code}: ${it.message}") - null - } - } - } - } + suspend fun getLatestRelease(updateUrl: String): Release? { + return withContext(Dispatchers.IO) { + val preferRelease = + PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean("updatePreferRelease", true) - /** - * Get the latest released version - */ - suspend fun getLatestRelease(updateUrl: String): Release? = - withContext(WholphinDispatchers.IO) { val request = Request .Builder() .url(updateUrl) .get() .build() - getRelease(request) - } - - private fun getRelease(request: Request): Release? { - return okHttpClient.newCall(request).execute().use { - if (it.isSuccessful) { - parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG) - } else { - Timber.w("Update check failed ${it.code}: ${it.message}") - null + okHttpClient.newCall(request).execute().use { + if (it.isSuccessful && it.body != null) { + val result = Json.parseToJsonElement(it.body!!.string()) + val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull + val version = Version.tryFromString(name) + val publishedAt = + result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull + val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull + val downloadUrl = + result.jsonObject["assets"] + ?.jsonArray + ?.let { assets -> getDownloadUrl(assets, preferRelease) } + Timber.v("version=$version, downloadUrl=$downloadUrl") + if (version != null) { + val notes = + if (body.isNotNullOrBlank()) { + NOTE_REGEX + .findAll(body) + .map { m -> + m.groupValues[1] + }.toList() + } else { + listOf() + } + return@use Release(version, downloadUrl, publishedAt, body, notes) + } else { + Timber.w("Update version parsing failed. name=$name") + } + } else { + Timber.w("Update check failed: ${it.message}") + } + return@use null } } } - /** - * Download and install an update - */ - @SuppressLint("RequestInstallPackagesPolicy") + private fun getDownloadUrl( + assets: JsonArray, + preferRelease: Boolean, + ): String? { + val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" } + val releaseSuffix = if (preferRelease) "-release" else "-debug" + val preferredNames = + listOf( + "$ASSET_NAME${releaseSuffix}$abiSuffix.apk", + "$ASSET_NAME$releaseSuffix.apk", + "$ASSET_NAME.apk", + ) + var preferredAsset: JsonObject? = null + outer@ for (name in preferredNames) { + for (asset in assets) { + val assetName = + asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull + if (name == assetName) { + preferredAsset = asset.jsonObject + break@outer + } + } + } + return preferredAsset + ?.get("browser_download_url") + ?.jsonPrimitive + ?.contentOrNull + } + suspend fun installRelease( release: Release, callback: DownloadCallback, ) { - withContext(WholphinDispatchers.IO) { + withContext(Dispatchers.IO) { cleanup() val request = Request @@ -242,9 +208,11 @@ class UpdateChecker .get() .build() okHttpClient.newCall(request).execute().use { - if (it.isSuccessful) { + if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") - callback.contentLength(it.body.contentLength()) + withContext(Dispatchers.Main) { + callback.contentLength(it.headersContentLength()) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { @@ -262,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) } @@ -322,28 +290,23 @@ 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) } } return targetFile } - /** - * Check if the app has permission to write the download - */ fun hasPermissions(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - ( - ContextCompat.checkSelfPermission( - context, - Manifest.permission.WRITE_EXTERNAL_STORAGE, - ) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_EXTERNAL_STORAGE, - ) == PackageManager.PERMISSION_GRANTED - ) + ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED /** * Delete previously downloaded APKs @@ -372,7 +335,7 @@ class UpdateChecker context.contentResolver.delete( MediaStore.Downloads.EXTERNAL_CONTENT_URI, "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? AND ${MediaStore.MediaColumns.MIME_TYPE} = ?", - arrayOf("$ASSET_NAME%", APK_MIME_TYPE), + arrayOf(context.getString(R.string.app_name) + "%", APK_MIME_TYPE), ) Timber.i("Deleted $deletedRows rows") } else { @@ -396,19 +359,7 @@ data class Release( val publishedAt: String?, val body: String?, val notes: List<String>, -) { - val content = - "# ${version}\n" + - ( - (notes.joinToString("\n").takeIf { it.isNotNullOrBlank() } ?: "") + - (body ?: "") - ).replace( - Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"), - "#$1", - ) - // Remove the last line for full changelog since it's just a link - .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "") -} +) interface DownloadCallback { fun contentLength(contentLength: Long) @@ -419,53 +370,19 @@ interface DownloadCallback { suspend fun copyTo( input: InputStream, out: OutputStream, - bufferSize: Int = 64 * 1024, + bufferSize: Int = 16 * 1024, callback: DownloadCallback, -): 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 +): 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) - bytes = input.read(buffer) } - return@withContext bytesCopied + bytes = input.read(buffer) } - -fun getDownloadUrl( - assets: JsonArray, - debug: Boolean, - supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(), -): String? { - val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" } - val releaseSuffix = if (debug) "-debug" else "-release" - val preferredNames = - buildList { - add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk") - add("$ASSET_NAME$releaseSuffix.apk") - if (!debug) { - if (abiSuffix.isNotNullOrBlank()) { - add("$ASSET_NAME$abiSuffix.apk") - } - add("$ASSET_NAME.apk") - } - } - var preferredAsset: JsonObject? = null - outer@ for (name in preferredNames) { - for (asset in assets) { - val assetName = - asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull - if (name == assetName) { - preferredAsset = asset.jsonObject - break@outer - } - } - } - return preferredAsset - ?.get("browser_download_url") - ?.jsonPrimitive - ?.contentOrNull + return bytesCopied } 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 deleted file mode 100644 index 03b8846f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.github.damontecres.wholphin.services - -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.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Get the current user's [UserPreferences] - */ -@Singleton -class UserPreferencesService - @Inject - constructor( - private val serverRepository: ServerRepository, - private val preferencesDataStore: DataStore<AppPreferences>, - ) { - 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 deleted file mode 100644 index b208d663..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.lifecycleScope -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.data.SeerrServerDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod -import com.github.damontecres.wholphin.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.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withContext -import timber.log.Timber -import javax.inject.Inject -import kotlin.coroutines.cancellation.CancellationException - -/** - * Listens for JF user switching in the app to also switch other settings like Seerr user/server - */ -@ActivityScoped -class UserSwitchListener - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val seerrServerRepository: SeerrServerRepository, - private val seerrServerDao: SeerrServerDao, - private val seerrApi: SeerrApi, - private val homeSettingsService: HomeSettingsService, - private val serverPluginApi: ServerPluginApi, - ) { - init { - context as AppCompatActivity - context.lifecycleScope.launchDefault { - serverRepository.currentUserFlow - .distinctUntilChanged { old, new -> - old?.id == new?.id && old?.uiLanguage == new?.uiLanguage - }.collectLatest { user -> - Timber.d("UserSwitch: %s", user?.id) - seerrServerRepository.clear() - homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } - if (user != null) { - switchUser(user) - } - } - } - } - - private suspend fun switchUser(user: JellyfinUser) = - supervisorScope { - // Switch the locale to either the user's choice or the system default (empty) - val localeList = - user.uiLanguage?.let { LocaleListCompat.forLanguageTags(it) } - ?: LocaleListCompat.getEmptyLocaleList() - Timber.i("Switching locale to %s", localeList) - withContext(WholphinDispatchers.Main) { - AppCompatDelegate.setApplicationLocales(localeList) - } - - // Check if plugin is installed, then for home settings & seerr settings - launchIO { - val serverPluginInstalled = - try { - serverPluginApi.checkInstalled() - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error checking for server plugin") - false - } - Timber.i("Server plugin installed: %s", serverPluginInstalled) - serverRepository.serverPluginInstalled.value = serverPluginInstalled - - launchIO { - // Check for home settings - homeSettingsService.loadCurrentSettings(user) - } - - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server - launchIO { - if (serverPluginInstalled) { - serverPluginApi.fetchSeerrSettings()?.let { - Timber.d("Trying seerr auto login") - val result = seerrServerRepository.usePluginProvidedServer(it) - if (result) { - // Successfully used server plugin provided seerr - Timber.i("Seerr auto login succeeded") - return@launchIO - } - } - } - Timber.v("Checking for existing configured seerr server") - // Either no server plugin seerr or there was an issue with it - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - seerrLogin( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) - } - } - } - } - } - } - } - } 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 3a114017..580a53ae 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,26 +1,28 @@ package com.github.damontecres.wholphin.services.hilt +import android.annotation.SuppressLint import android.content.Context -import androidx.work.WorkManager +import android.provider.Settings +import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.MostRecentServerProvider -import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.SeerrApi -import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.WholphinDispatchers +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.util.ExceptionHandler +import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import 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 import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -29,52 +31,18 @@ import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Qualifier import javax.inject.Singleton -/** - * An [OkHttpClient] that includes the user's access token when making requests - */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthOkHttpClient -/** - * A basic [OkHttpClient] that does not include auth - */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class StandardOkHttpClient -/** - * A [CoroutineScope] with [WholphinDispatchers.IO] - */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope -/** - * A [CoroutineScope] with [WholphinDispatchers.Default] - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DefaultCoroutineScope - -/** - * [WholphinDispatchers.IO] - * - * @see IoCoroutineScope - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class IoDispatcher - -/** - * [WholphinDispatchers.Default] - * - * @see DefaultCoroutineScope - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DefaultDispatcher - @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -88,6 +56,19 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = + DeviceInfo( + id = @SuppressLint("HardwareIds") Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + ), + name = Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME), + ) + @StandardOkHttpClient @Provides @Singleton @@ -111,7 +92,7 @@ object AppModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.current.value?.user?.accessToken?.let { token -> + serverRepository.currentUser.value?.accessToken?.let { token -> request .newBuilder() .addHeader( @@ -132,12 +113,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + ) = OkHttpFactory(okHttpClient) @Provides @Singleton fun jellyfin( - okHttpFactory: CoroutineContextApiClientFactory, + okHttpFactory: OkHttpFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, @@ -155,55 +136,50 @@ object AppModule { @Singleton fun apiClient(jellyfin: Jellyfin) = jellyfin.createApi() + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ @Provides @Singleton - @IoDispatcher - fun ioDispatcher(): CoroutineDispatcher = WholphinDispatchers.IO + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore<AppPreferences>, + @IoCoroutineScope scope: CoroutineScope, + ) = object : RememberTabManager { + fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.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 { + preferences.appPreferences.updateInterfacePreferences { + putRememberedTabs(key(itemId), tabIndex) + } + } + } + } + } + } @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope( - @IoDispatcher dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - - @Provides - @Singleton - @DefaultDispatcher - fun defaultDispatcher(): CoroutineDispatcher = WholphinDispatchers.Default - - @Provides - @Singleton - @DefaultCoroutineScope - fun defaultCoroutineScope( - @DefaultDispatcher dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - - @Provides - @Singleton - fun workManager( - @ApplicationContext context: Context, - ): WorkManager = WorkManager.getInstance(context) - - @Provides - @Singleton - fun seerrApi( - @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = SeerrApi(okHttpClient) - - @Provides - @Singleton - fun mostRecentServerProvider( - @ApplicationContext context: Context, - ): MostRecentServerProvider = MostRecentServerSharedPreferences(context) -} - -@Module -@InstallIn(SingletonComponent::class) -object DeviceModule { - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) + fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } 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 78270a9b..dbf61912 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 @@ -5,20 +5,12 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations -import com.github.damontecres.wholphin.data.PlaybackEffectDao -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao -import com.github.damontecres.wholphin.data.RememberedTabDao -import com.github.damontecres.wholphin.data.SeerrServerDao -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -45,7 +37,7 @@ object DatabaseModule { ).addMigrations(Migrations.Migrate2to3) // .setQueryCallback({ sqlQuery, args -> // Timber.v("sqlQuery=$sqlQuery, args=$args") -// }, WholphinDispatchers.IO.asExecutor()) +// }, Dispatchers.IO.asExecutor()) .build() @Provides @@ -64,26 +56,6 @@ object DatabaseModule { @Singleton fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() - @Provides - @Singleton - fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() - - @Provides - @Singleton - fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() - - @Provides - @Singleton - fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() - - @Provides - @Singleton - fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() - - @Provides - @Singleton - fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() - @Provides @Singleton fun userPreferencesDataStore( @@ -98,13 +70,4 @@ object DatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) - - @Provides - @Singleton - fun keyValueDataStore( - @ApplicationContext context: Context, - ): DataStore<Preferences> = - PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("key_value") }, - ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt deleted file mode 100644 index 336be1fb..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.github.damontecres.wholphin.services.tvprovider - -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import androidx.work.BackoffPolicy -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.await -import androidx.work.workDataOf -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.ui.collectLatestIn -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.util.ExceptionHandler -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped -import timber.log.Timber -import javax.inject.Inject -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds -import kotlin.time.toJavaDuration - -/** - * Schedules the [TvProviderWorker] to update the OS resume watching row - */ -@ActivityScoped -class TvProviderSchedulerService - @Inject - constructor( - @param:ActivityContext private val context: Context, - private val serverRepository: ServerRepository, - private val workManager: WorkManager, - ) { - private val activity = (context as AppCompatActivity) - - private val supportsTvProvider = - // TODO <=25 has limited support - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) - - init { - serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> - workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) - if (supportsTvProvider) { - if (user != null) { - // Schedule a new worker whenever the user changes - activity.lifecycleScope.launchIO(ExceptionHandler()) { - Timber.i("Scheduling TvProviderWorker for ${user.user}") - workManager - .enqueueUniquePeriodicWork( - uniqueWorkName = TvProviderWorker.WORK_NAME, - existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = - PeriodicWorkRequestBuilder<TvProviderWorker>( - repeatInterval = 1.hours.toJavaDuration(), - ).setBackoffCriteria( - BackoffPolicy.LINEAR, - 15.minutes.toJavaDuration(), - ).setInputData( - workDataOf( - TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), - TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), - ), - ).setInitialDelay(60.seconds.toJavaDuration()) - .build(), - ).await() - } - } - } - } - } - - /** - * Run the [TvProviderWorker] as a one-off instead of scheduled - */ - fun launchOneTimeRefresh() { - if (supportsTvProvider) { - activity.lifecycleScope.launchIO(ExceptionHandler()) { - serverRepository.current.value?.let { user -> - Timber.i("Scheduling on-time TvProviderWorker for ${user.user}") - workManager.enqueue( - OneTimeWorkRequestBuilder<TvProviderWorker>() - .setInputData( - workDataOf( - TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), - TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), - ), - ).build(), - ) - } - } - } - } - } 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 deleted file mode 100644 index a4d73caa..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ /dev/null @@ -1,409 +0,0 @@ -package com.github.damontecres.wholphin.services.tvprovider - -import android.content.ContentUris -import android.content.Context -import android.content.Intent -import android.database.Cursor -import android.net.Uri -import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.datastore.core.DataStore -import androidx.hilt.work.HiltWorker -import androidx.tvprovider.media.tv.Channel -import androidx.tvprovider.media.tv.PreviewProgram -import androidx.tvprovider.media.tv.TvContractCompat -import androidx.tvprovider.media.tv.TvContractCompat.Channels -import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms -import androidx.tvprovider.media.tv.WatchNextProgram -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import com.github.damontecres.wholphin.MainActivity -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.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 -import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.firstOrNull -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.ApiClientException -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.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.extensions.ticks -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.time.ZoneId -import java.util.Date -import java.util.UUID -import kotlin.time.Duration.Companion.minutes - -/** - * Updates the Android OS resume watching row for a given Jellyfin user - * - * @see TvProviderSchedulerService - */ -@HiltWorker -class TvProviderWorker - @AssistedInject - constructor( - @Assisted private val context: Context, - @Assisted workerParams: WorkerParameters, - private val serverRepository: ServerRepository, - private val preferences: DataStore<AppPreferences>, - private val api: ApiClient, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - ) : CoroutineWorker(context, workerParams) { - override suspend fun doWork(): Result { - Timber.d("Start") - val serverId = - inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() - val userId = - inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() - - if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - // Not active - val result = serverRepository.restoreLastSession() - if (result !is RestoredSession.Success) { - Timber.w("No user found during run") - return Result.failure() - } - } - try { - val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val potentialItemsToAdd = - getPotentialItems( - userId, - prefs.homePagePreferences.enableRewatchingNextUp, - prefs.homePagePreferences.maxDaysNextUp, - ) - val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } - - Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds) - val currentItems = getCurrentTvChannelNextUp() - val currentItemIds = currentItems.map { it.internalProviderId } - - // TODO Remove after v0.6.5 release - // This cleans up movies that are using the poster image, see https://github.com/damontecres/Wholphin/issues/1425 - currentItems - .filter { - it.type == WatchNextPrograms.TYPE_MOVIE && - it.posterArtUri - .toString() - .lowercase() - .contains("primary") - }.map { TvContractCompat.buildWatchNextProgramUri(it.id) } - .forEach { - context.contentResolver.delete(it, null, null) - } - // End temporary clean up - - val toRemove = - currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } - - val userRemoved = currentItems.filterNot { it.isBrowsable } - val userRemovedIds = userRemoved.map { it.internalProviderId } - Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) - val toAdd = - potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds } - Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) - - // Remove existing items if they are no longer in the next up from server - (toRemove + userRemoved) - .map { TvContractCompat.buildWatchNextProgramUri(it.id) } - .forEach { - context.contentResolver.delete(it, null, null) - } - - // Add new ones - val addedCount = - context.contentResolver.bulkInsert( - WatchNextPrograms.CONTENT_URI, - toAdd - .map { convert(it).toContentValues() } - .toTypedArray(), - ) - Timber.v("Added %s", addedCount) - - addOtherChannels() - - Timber.d("Completed successfully") - } catch (_: ApiClientException) { - return Result.retry() - } catch (_: Exception) { - return Result.failure() - } - return Result.success() - } - - private suspend fun getPotentialItems( - userId: UUID, - enableRewatching: Boolean, - maxDaysNextUp: Int, - ): List<BaseItem> { - val resumeItems = latestNextUpService.getResume(userId, 10, true) - val seriesIds = resumeItems.mapNotNull { it.data.seriesId } - val nextUpItems = - latestNextUpService - .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) - .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } - return latestNextUpService.buildCombined(resumeItems, nextUpItems) - } - - private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> = - context.contentResolver - .query( - WatchNextPrograms.CONTENT_URI, - WatchNextProgram.PROJECTION, - null, - null, - null, - )?.map(WatchNextProgram::fromCursor) - .orEmpty() - - private fun convert(item: BaseItem): WatchNextProgram = - WatchNextProgram - .Builder() - .apply { - val dto = item.data - setInternalProviderId(item.id.toString()) - - val type = - when (item.type) { - BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE - BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE - else -> WatchNextPrograms.TYPE_CLIP - } - setType(type) - - val resumePosition = dto.userData?.playbackPositionTicks?.ticks - if (resumePosition != null && resumePosition >= 2.minutes) { - // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content - setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) - setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) - } else { - setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) - } - dto.runTimeTicks - ?.ticks - ?.inWholeMilliseconds - ?.toInt() - ?.let(::setDurationMillis) - - setLastEngagementTimeUtcMillis( - dto.userData - ?.lastPlayedDate - ?.atZone(ZoneId.systemDefault()) - ?.toEpochSecond() - ?: Date().time, // TODO - ) - - setTitle(item.title) - setDescription(dto.overview) - if (item.type == BaseItemKind.EPISODE) { - setEpisodeTitle(item.name) - dto.indexNumber?.let(::setEpisodeNumber) - dto.parentIndexNumber?.let(::setSeasonNumber) - } - - setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) - val imageType = - when (item.type) { - BaseItemKind.EPISODE -> ImageType.THUMB - else -> if (ImageType.THUMB in item.data.imageTags.orEmpty()) ImageType.THUMB else ImageType.PRIMARY - } - setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) - - setIntent( - Intent(context, MainActivity::class.java) - .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) - .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) - .apply { - if (item.type == BaseItemKind.EPISODE) { - putExtra( - IntentService.INTENT_SERIES_ID, - dto.seriesId?.toString(), - ) - putExtra( - IntentService.INTENT_SEASON_ID, - dto.seasonId?.toString(), - ) - dto.parentIndexNumber?.let { - putExtra( - IntentService.INTENT_SEASON_NUMBER, - it, - ) - } - dto.indexNumber?.let { - putExtra( - IntentService.INTENT_EPISODE_NUMBER, - it, - ) - } - } - }, - ) - }.build() - - private suspend fun addOtherChannels() { - val preferences = preferences.data.firstOrNull() - val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE) - - val latest = - api.userLibraryApi - .getLatestMedia( - GetLatestMediaRequest( - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), - imageTypeLimit = 1, - parentId = null, - groupItems = true, - limit = - preferences?.homePagePreferences?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt(), - isPlayed = null, // Server will handle user's preference - ), - ).content - .map { BaseItem(it, true) } - - var channelId = channelsPrefs.getString("latest", null)?.toUri() - if (channelId == null) { - Timber.d("channelId for latest is null") - val channel = - Channel - .Builder() - .apply { - setDisplayName(context.getString(R.string.recently_added)) - setType(Channels.TYPE_PREVIEW) - setAppLinkIntent(Intent(context, MainActivity::class.java)) - }.build() - channelId = - context.contentResolver.insert( - Channels.CONTENT_URI, - channel.toContentValues(), - ) - if (channelId != null) { - channelsPrefs.edit(true) { - putString("latest", channelId.toString()) - } - TvContractCompat.requestChannelBrowsable( - context, - ContentUris.parseId(channelId), - ) - } else { - Timber.w("channelId was null") - throw IllegalStateException("channelId was null") - } - } - val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray() - - // Delete & replace - context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null) - val count = - context.contentResolver.bulkInsert( - TvContractCompat.PreviewPrograms.CONTENT_URI, - programs, - ) - Timber.v("Inserted $count records") - } - - private fun convert( - channelId: Uri, - item: BaseItem, - ): PreviewProgram = - PreviewProgram - .Builder() - .apply { - setChannelId(ContentUris.parseId(channelId)) - - val dto = item.data - setInternalProviderId(item.id.toString()) - - val type = - when (item.type) { - BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES - BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON - BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE - BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE - else -> WatchNextPrograms.TYPE_CLIP - } - setType(type) - - val resumePosition = dto.userData?.playbackPositionTicks?.ticks - if (resumePosition != null && resumePosition >= 2.minutes) { - // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content - setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) - } - dto.runTimeTicks - ?.ticks - ?.inWholeMilliseconds - ?.toInt() - ?.let(::setDurationMillis) - - setTitle(item.title) - setDescription(dto.overview) - if (item.type == BaseItemKind.EPISODE) { - setEpisodeTitle(item.name) - dto.indexNumber?.let(::setEpisodeNumber) - dto.parentIndexNumber?.let(::setSeasonNumber) - } - - setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) - setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri()) - - setIntent( - Intent(context, MainActivity::class.java) - .putExtra(IntentService.INTENT_ITEM_ID, item.id.toString()) - .putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName) - .apply { - if (item.type == BaseItemKind.EPISODE) { - putExtra( - IntentService.INTENT_SERIES_ID, - dto.seriesId?.toString(), - ) - putExtra( - IntentService.INTENT_SEASON_ID, - dto.seasonId?.toString(), - ) - dto.parentIndexNumber?.let { - putExtra( - IntentService.INTENT_SEASON_NUMBER, - it, - ) - } - dto.indexNumber?.let { - putExtra( - IntentService.INTENT_EPISODE_NUMBER, - it, - ) - } - } - }, - ) - }.build() - - companion object { - const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" - const val PARAM_USER_ID = "userId" - const val PARAM_SERVER_ID = "serverId" - } - } - -fun <T> Cursor.map(transform: (Cursor) -> T): List<T> = - this.use { - buildList { - if (moveToFirst()) { - do { - add(transform.invoke(this@map)) - } while (moveToNext()) - } - } - } 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 703182ba..45205902 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 @@ -1,134 +1,47 @@ package com.github.damontecres.wholphin.ui import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import coil3.ImageLoader import coil3.annotation.ExperimentalCoilApi import coil3.compose.setSingletonImageLoaderFactory 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 import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import coil3.request.Options import coil3.request.crossfade import coil3.util.DebugLogger -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import okhttp3.OkHttpClient -import timber.log.Timber import kotlin.time.ExperimentalTime -@Composable -fun CoilConfig( - prefs: AppPreferences, - okHttpClient: OkHttpClient, - debugLogging: Boolean, - enableCache: Boolean = true, -) = CoilConfig( - diskCacheSizeBytes = - prefs.advancedPreferences.imageDiskCacheSizeBytes.let { - if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) { - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT - } else { - it - } - }, - okHttpClient = okHttpClient, - debugLogging = debugLogging, - enableCache = enableCache, -) - /** * Configure Coil image loading */ @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @Composable fun CoilConfig( - diskCacheSizeBytes: Long, okHttpClient: OkHttpClient, debugLogging: Boolean, - enableCache: Boolean = true, ) { - val client = - remember(okHttpClient, debugLogging) { - if (debugLogging) { - okHttpClient - .newBuilder() - .addInterceptor { - val start = System.currentTimeMillis() - val req = it.request() - val res = it.proceed(req) - val time = System.currentTimeMillis() - start - Timber.v("${time}ms - ${req.url}") - res - }.build() - } else { - okHttpClient - } - } setSingletonImageLoaderFactory { ctx -> - Timber.i("Image diskCacheSizeBytes=$diskCacheSizeBytes") ImageLoader .Builder(ctx) - .apply { - if (enableCache) { - memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build()) - diskCache( - DiskCache - .Builder() - .directory(ctx.cacheDir.resolve("coil3_image_cache")) - .maxSizeBytes(diskCacheSizeBytes) - .build(), - ) - } else { - memoryCache(null) - diskCache(null) - } - }.crossfade(false) + .memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build()) + .diskCache( + DiskCache + .Builder() + .directory(ctx.cacheDir.resolve("coil3_image_cache")) + .maxSizeBytes(100L * 1024 * 1024) + .build(), + ).crossfade(false) .logger(if (debugLogging) DebugLogger() else null) .components { add( OkHttpNetworkFetcherFactory( - cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, - callFactory = { client }, - // Disable connectivity checker which may be unreliable for VPNs and/or some versions of Android - connectivityChecker = { ConnectivityChecker { true } }, + cacheStrategy = { CacheControlCacheStrategy() }, + callFactory = { okHttpClient }, ), ) }.build() } } - -/** - * This [CacheStrategy] always prefers the cached response for Trickplay images, - * otherwise the decision is delegated to the provided [CacheStrategy] - * - * The expectation is that Trickplay images will be prefetched so the cache will always be warm - */ -@OptIn(ExperimentalCoilApi::class) -private class WholphinCacheStrategy( - private val delegate: CacheStrategy, -) : CacheStrategy { - override suspend fun read( - cacheResponse: NetworkResponse, - networkRequest: NetworkRequest, - options: Options, - ): CacheStrategy.ReadResult = - if (networkRequest.url.contains("/Trickplay/")) { - CacheStrategy.ReadResult(cacheResponse) - } else { - delegate.read(cacheResponse, networkRequest, options) - } - - override suspend fun write( - cacheResponse: NetworkResponse?, - networkRequest: NetworkRequest, - networkResponse: NetworkResponse, - options: Options, - ): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt index dc91eb3b..ace495bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilTrickplayTransformation.kt @@ -18,7 +18,7 @@ class CoilTrickplayTransformation( val index: Int, ) : Transformation() { private val x: Int = imageIndex % numColumns - private val y: Int = imageIndex / numColumns + private val y: Int = imageIndex / numRows override val cacheKey: String get() = "CoilTrickplayTransformation_$index,$x,$y" 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 312bc6cc..767d48ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent +import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -18,8 +19,6 @@ 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.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.onPreviewKeyEvent @@ -27,26 +26,20 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.MutableLiveData import androidx.media3.common.Player import coil3.request.ErrorResult -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 -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.ACRA -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -56,6 +49,7 @@ import kotlin.contracts.contract import kotlin.coroutines.CoroutineContext import kotlin.math.min import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -74,18 +68,15 @@ 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 */ -fun FocusRequester.tryRequestFocus(tag: String? = null): Boolean = +fun FocusRequester.tryRequestFocus(): Boolean = try { requestFocus() - tag?.let { Timber.v("Request focus tag=%s", tag) } true } catch (ex: IllegalStateException) { - Timber.w(ex, "Failed to request focus, tag=%s", tag) + Timber.w(ex, "Failed to request focus") false } @@ -169,26 +160,6 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { } } -/** - * Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes - */ -@Composable -fun RequestOrRestoreFocus( - focusRequester: FocusRequester?, - debugKey: String? = null, -) { - if (focusRequester != null) { - LaunchedEffect(Unit) { - debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) } - focusRequester.tryRequestFocus() - } -// LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { -// debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } -// focusRequester.tryRequestFocus() -// } - } -} - fun Modifier.enableMarquee(focused: Boolean) = if (focused) { basicMarquee( @@ -235,7 +206,7 @@ val Duration.roundMinutes: Duration * Rounds a [Duration] to nearest whole second */ val Duration.roundSeconds: Duration - get() = (this + .5.seconds).inWholeSeconds.seconds + get() = (this + 30.milliseconds).inWholeSeconds.seconds /** * Gets the user's playback position as a [Duration] @@ -244,7 +215,7 @@ val BaseItemDto.timeRemaining: Duration? get() = userData?.playbackPositionTicks?.let { if (it > 0) { - runTimeTicks?.minus(it)?.ticks?.takeIf { it > Duration.ZERO } + runTimeTicks?.minus(it)?.ticks } else { null } @@ -298,7 +269,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context] + * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -312,6 +283,18 @@ fun Context.findActivity(): Activity? { return null } +/** + * Keep the screen on for an [Activity]. Often used with [findActivity]. + */ +fun Activity.keepScreenOn(keep: Boolean) { + Timber.v("Keep screen on: $keep") + if (keep) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } +} + /** * Selectively log errors from Coil image loading. * @@ -321,7 +304,7 @@ fun logCoilError( url: String?, errorResult: ErrorResult, ) { - if (errorResult.throwable is coil3.network.HttpException || errorResult.throwable is coil3.request.NullRequestDataException) { + if (errorResult.throwable is coil3.network.HttpException) { Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url) } else { Timber.e(errorResult.throwable, "Error loading image: %s", url) @@ -361,34 +344,25 @@ suspend fun showToast( context: Context, text: CharSequence, duration: Int, -) = withContext(WholphinDispatchers.Main) { +) = withContext(Dispatchers.Main) { Toast.makeText(context, text, duration).show() } suspend fun showToast( context: Context, text: CharSequence, -) = withContext(WholphinDispatchers.Main) { +) = withContext(Dispatchers.Main) { Toast.makeText(context, text, Toast.LENGTH_LONG).show() } /** - * Launches a coroutine with [WholphinDispatchers.IO] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + * Launches a coroutine with [Dispatchers.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 = WholphinDispatchers.IO + context, start = start, block = block) - -/** - * 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 = WholphinDispatchers.Default + context, start = start, block = block) +): Job = launch(context = Dispatchers.IO + context, start = start, block = block) /** * Converts a UUID to the format used server-side (ie without hyphens). @@ -398,6 +372,11 @@ fun CoroutineScope.launchDefault( */ fun UUID.toServerString() = this.toString().replace("-", "") +suspend fun <T> MutableLiveData<T>.setValueOnMain(value: T) = + withContext(Dispatchers.Main) { + this@setValueOnMain.value = value + } + fun equalsNotNull( a: Any?, b: Any?, @@ -412,54 +391,4 @@ fun logTab( ACRA.errorReporter.putCustomData("tabInfo", info) } -suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(WholphinDispatchers.Main, block) - -fun Modifier.dimAndBlur(enabled: Boolean) = - this.ifElse( - enabled, - Modifier - .alpha(.5f) - .blur(16.dp), - ) - -fun Response<BaseItemDtoQueryResult>.toBaseItems( - api: ApiClient, - 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 - */ -fun Int?.gt(that: Int) = (this ?: 0) > that - -fun Int?.lt(that: Int) = (this ?: 0) < that - -/** - * Simplifies endlessly collecting a flow - */ -fun <T> Flow<T>.collectLatestIn( - scope: CoroutineScope, - action: suspend (value: T) -> Unit, -) { - scope.launchDefault { this@collectLatestIn.collectLatest(action) } -} - -/** - * 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) - } +suspend fun <T> onMain(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) 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 a284ef0b..34f7f134 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,171 +1,71 @@ package com.github.damontecres.wholphin.ui -import android.content.Context -import android.content.res.Resources -import android.text.format.DateFormat +import android.os.Build import androidx.annotation.StringRes -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.preferences.AppPreference.Companion.MEGA_BIT -import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported -import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind 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 -/** - * 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 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 = - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) - -fun getDateFormatter(): DateTimeFormatter { - if (dateFormatter.locale != Locale.getDefault()) { - dateFormatter = dateFormatter.withLocale(Locale.getDefault()) - } - return dateFormatter -} - -// TODO server returns in UTC, but sdk converts to local time -// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 +val TimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) /** * Format a [LocalDateTime] as `Aug 24, 2000` */ -fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) - -fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime) - -fun formatDate(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime) - -fun toLocalDate(date: String?): LocalDate? = - date?.let { - try { - LocalDate.parse(it) - } catch (_: DateTimeParseException) { - Timber.w("Could not parse date: %s", date) - null - } - } - -// 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) -} - -/** - * 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) +fun formatDateTime(dateTime: LocalDateTime): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TODO server returns in UTC, but sdk converts to local time + // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 + val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + formatter.format(dateTime) + } else if (dateTime.toString().length >= 10) { + dateTime.toString().substring(0, 10) } else { - formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode) + dateTime.toString() } -} -/** 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) +fun formatDate(dateTime: LocalDate): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TODO server returns in UTC, but sdk converts to local time + // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 + val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + formatter.format(dateTime) + } else if (dateTime.toString().length >= 10) { + dateTime.toString().substring(0, 10) + } else { + dateTime.toString() + } /** - * If the item has season & episode info, format as localized `S# E#` + * If the item has season & episode info, format as `S# E#` */ val BaseItemDto.seasonEpisode: String? get() = - formatSeasonEpisode( - parentIndexNumber, - indexNumber, - indexNumberEnd, - ) + 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 + } /** - * If the item has season & episode info, format padded as localized `S##E##` + * If the item has season & episode info, format padded as `S## E##` */ val BaseItemDto.seasonEpisodePadded: String? get() = - formatSeasonEpisode( - parentIndexNumber, - indexNumber, - indexNumberEnd, - padded = true, - ) - -val BaseItemDto.seriesProductionYears: String? - get() = - if (productionYear != null) { - buildString { - append(productionYear.toString()) - if (status == "Continuing") { - append(" - ") - append(WholphinApplication.instance.getString(R.string.series_continueing)) - } else if (status == "Ended") { - endDate?.let { - if (it.year != productionYear) { - append(" - ") - append(it.year) - } - } - } + 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 @@ -189,32 +89,30 @@ fun abbreviateNumber(number: Int): String { return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) } -val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB") +val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") +/** + * Format bytes + */ +fun formatBytes( + bytes: Int, + suffixes: List<String> = byteSuffixes, +) = formatBytes(bytes.toLong(), suffixes) + fun formatBytes( bytes: Long, suffixes: List<String> = byteSuffixes, - divisor: Int = 1024, ): String { var unit = 0 var count = bytes.toDouble() - while (count >= divisor && unit + 1 < suffixes.size) { - count /= divisor + while (count >= 1024 && unit + 1 < suffixes.size) { + count /= 1024 unit++ } - return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit]) + return String.format(Locale.getDefault(), "%.2f%s", count, suffixes[unit]) } -fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) - -fun formatMaxBitrate(bitrate: Long) = - if (bitrate < MEGA_BIT) { - "${bitrate / 1024} kbps" - } else { - "${bitrate / MEGA_BIT} Mbps" - } - @get:StringRes val MediaSegmentType.stringRes: Int get() = @@ -238,127 +136,3 @@ val MediaSegmentType.skipStringRes: Int MediaSegmentType.OUTRO -> R.string.skip_segment_outro MediaSegmentType.INTRO -> R.string.skip_segment_intro } - -fun AnnotatedString.Builder.dot() = append(" \u2022 ") - -fun listToDotString(strings: List<String>): AnnotatedString = - buildAnnotatedString { - strings.forEachIndexed { index, string -> - append(string) - if (index != strings.lastIndex) dot() - } - } - -@get:StringRes -val BaseItemKind.titleStringRes: Int - get() = formatTypeName(this) - -@StringRes -fun formatTypeName(type: BaseItemKind): Int = - when (type) { - 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_title - BaseItemKind.BOX_SET -> R.string.collections - 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 -> 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 -> 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_title - BaseItemKind.TV_CHANNEL -> R.string.channels - 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) - } - } - -fun Resources.serverSupportedMessage( - serverVersionSupported: ServerVersionSupported, - serverVersion: ServerVersion?, -): String? { - val errorMsg = - when (serverVersionSupported) { - ServerVersionSupported.SUPPORTED -> { - null - } - - ServerVersionSupported.NOT_SUPPORTED, - ServerVersionSupported.UNKNOWN, - -> { - getString(R.string.server_version_not_supported) - } - - ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { - getString(R.string.server_version_not_supported_future) - } - } - return errorMsg?.let { - val msg = - if (serverVersion != null) { - "$it: $serverVersion" - } else { - it + ": " + getString(R.string.unknown) - } - msg + "\n" + - when (serverVersionSupported) { - ServerVersionSupported.SUPPORTED -> { - "" - } - - ServerVersionSupported.NOT_SUPPORTED, - ServerVersionSupported.UNKNOWN, - -> { - getString( - R.string.server_version_required, - WholphinApplication.minimumServerVersion.toString(), - ) - } - - ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { - getString( - R.string.server_version_required, - WholphinApplication.newMinimumServerVersion.toString(), - ) - } - } - } -} 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 786db3d5..c15fe0b4 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 @@ -1,62 +1,52 @@ package com.github.damontecres.wholphin.ui import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION -import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.services.ImageUrlService import org.jellyfin.sdk.model.api.ItemFields // This file is for constants used for the UI val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) -val LocalImageUrlService = - staticCompositionLocalOf<ImageUrlService> { throw IllegalStateException("LocalImageUrlService not set") } - /** * Colors not associated with the theme */ -object AppColors { - val TransparentBlack25 = Color(0x40000000) - val TransparentBlack50 = Color(0x80000000) - val TransparentBlack75 = Color(0xBF000000) +sealed class AppColors private constructor() { + companion object { + val TransparentBlack25 = Color(0x40000000) + val TransparentBlack50 = Color(0x80000000) + val TransparentBlack75 = Color(0xBF000000) - val DarkGreen = Color(0xFF114000) - val DarkRed = Color(0xFF400000) - val DarkCyan = Color(0xFF21556E) - val DarkPurple = Color(0xFF261370) + val DarkGreen = Color(0xFF114000) + val DarkRed = Color(0xFF400000) + val DarkCyan = Color(0xFF21556E) + val DarkPurple = Color(0xFF261370) - val GoldenYellow = Color(0xFFDAB440) - - object Discover { - val Blue = Color(37, 99, 235) - val Purple = Color(147, 51, 234) - val Green = Color(74, 222, 128) - val Yellow = Color(234, 179, 8) - val Red = Color(204, 38, 38) + val GoldenYellow = Color(0xFFDAB440) } } const val DEFAULT_PAGE_SIZE = 100 /** - * The [ItemFields] to fetch for detailed paged and playback + * The default [ItemFields] to fetch for most queries */ -val DetailItemFields = +val DefaultItemFields = 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.CAN_DELETE, - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ) /** @@ -64,50 +54,24 @@ val DetailItemFields = */ 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.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 - -/** - * [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id - * for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request. - */ -val HomeItemFields = - listOf( - ItemFields.OVERVIEW, - ItemFields.CAN_DELETE, - ItemFields.PROVIDER_IDS, +val DefaultButtonPadding = + PaddingValues( + start = 12.dp / 2, + top = 10.dp / 2, + end = 16.dp / 2, + bottom = 10.dp / 2, ) -/** - * [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields] - */ -val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS - -val PhotoItemFields = - 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 height2x3 = 180.dp val playedPercentHeight = 6.dp - val serverUserCircle = height2x3 * .75f } object AspectRatios { @@ -115,17 +79,6 @@ object AspectRatios { const val FOUR_THREE = 4f / 3f const val TALL = 2f / 3f const val SQUARE = 1f - - const val MIN = TALL -} - -enum class AspectRatio( - val ratio: Float, -) { - TALL(AspectRatios.TALL), - WIDE(AspectRatios.WIDE), - FOUR_THREE(AspectRatios.FOUR_THREE), - SQUARE(AspectRatios.SQUARE), } @Preview( 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 4536a364..28de1f7b 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 @@ -2,37 +2,30 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -40,14 +33,11 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.enableMarquee -import org.jellyfin.sdk.model.api.ImageType +import com.github.damontecres.wholphin.ui.isNotNullOrBlank /** * Displays an image as a card. If no image is available, the name will be shown instead @@ -55,7 +45,7 @@ import org.jellyfin.sdk.model.api.ImageType @Composable fun BannerCard( name: String?, - item: BaseItem?, + imageUrl: String?, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -63,50 +53,15 @@ fun BannerCard( played: Boolean = false, favorite: Boolean = false, playPercent: Double = 0.0, - cardHeight: Dp = 120.dp, + cardHeight: Dp = 140.dp * .85f, aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, - imageType: ImageType = ImageType.PRIMARY, - imageContentScale: ContentScale = ContentScale.FillBounds, - useSeriesForPrimary: Boolean = true, ) { - val imageUrlService = LocalImageUrlService.current - val density = LocalDensity.current - val fillHeight = - remember(cardHeight, density) { - if (cardHeight.isSpecified) { - with(density) { - cardHeight.roundToPx() - } - } else { - null - } - } - val imageUrl = - remember(item, fillHeight, imageType, useSeriesForPrimary) { - if (item != null) { - item.imageUrlOverride - ?: imageUrlService.getItemImageUrl( - item, - imageType, - fillWidth = null, - fillHeight = fillHeight, - useSeriesForPrimary = useSeriesForPrimary, - ) - } else { - null - } - } - var imageError by remember(imageUrl) { mutableStateOf(false) } - - // Stabilize callbacks to prevent AsyncImage from recomposing - val currentOnClick by rememberUpdatedState(onClick) - val currentOnLongClick by rememberUpdatedState(onLongClick) - + var imageError by remember { mutableStateOf(false) } Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), - onClick = { currentOnClick() }, - onLongClick = { currentOnLongClick() }, + onClick = onClick, + onLongClick = onLongClick, interactionSource = interactionSource, colors = CardDefaults.colors( @@ -119,19 +74,19 @@ fun BannerCard( .fillMaxSize(), // .background(MaterialTheme.colorScheme.surfaceVariant), ) { - if (!imageError && imageUrl != null) { + if (!imageError && imageUrl.isNotNullOrBlank()) { AsyncImage( model = imageUrl, contentDescription = null, - contentScale = imageContentScale, - onError = remember { { imageError = true } }, + contentScale = ContentScale.FillBounds, + onError = { imageError = true }, modifier = Modifier.fillMaxSize(), ) } else { Text( text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, modifier = Modifier @@ -139,7 +94,7 @@ fun BannerCard( .align(Alignment.Center), ) } - if (played || cornerText != null) { + if (played || cornerText.isNotNullOrBlank()) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -151,7 +106,7 @@ fun BannerCard( if (played && (playPercent <= 0 || playPercent >= 100)) { WatchedIcon(Modifier.size(24.dp)) } - if (cornerText != null) { + if (cornerText.isNotNullOrBlank()) { Box( modifier = Modifier @@ -197,76 +152,3 @@ fun BannerCard( } } } - -@Composable -fun BannerCardWithTitle( - title: String?, - subtitle: String?, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - cornerText: String? = null, - played: Boolean = false, - favorite: Boolean = false, - playPercent: Double = 0.0, - cardHeight: Dp = 120.dp, - aspectRatio: Float = AspectRatios.WIDE, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - imageType: ImageType = ImageType.PRIMARY, - imageContentScale: ContentScale = ContentScale.FillBounds, - useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, -) { - val focused by interactionSource.collectIsFocusedAsState() - val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) - val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) - val width = cardHeight * aspectRationToUse - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.width(width), - ) { - BannerCard( - name = null, - item = item, - onClick = onClick, - onLongClick = onLongClick, - modifier = Modifier, - cornerText = cornerText, - played = played, - favorite = favorite, - playPercent = playPercent, - cardHeight = cardHeight, - aspectRatio = aspectRatio, - interactionSource = interactionSource, - imageType = imageType, - imageContentScale = imageContentScale, - useSeriesForPrimary = useSeriesForPrimary, - ) - SlidingCardText(focused) { - Text( - text = title ?: "", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = subtitle ?: "", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Normal, - maxLines = 1, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterCard.kt index 34966e43..4739b80c 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,62 +5,39 @@ 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.height +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size 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 org.jellyfin.sdk.model.api.ImageType +import kotlin.time.Duration /** * Card for a [com.github.damontecres.wholphin.data.model.Chapter] */ @Composable fun ChapterCard( - chapter: Chapter, + name: String?, + position: Duration, + imageUrl: String?, onClick: () -> Unit, + aspectRatio: Float, modifier: Modifier = Modifier, - cardHeight: Dp = 120.dp, + cardHeight: Dp = 140.dp * .85f, 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.height(cardHeight), + modifier = modifier.size(cardHeight * aspectRatio, cardHeight), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -71,36 +48,22 @@ 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)) { - chapter.name?.let { + 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 = positionText, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, + text = position.roundSeconds.toString(), ) } } 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 5fa6316f..cf799c54 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,6 +21,7 @@ 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, @@ -33,7 +34,6 @@ fun ChapterRow( text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -41,13 +41,17 @@ fun ChapterRow( contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), modifier = Modifier + .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(), ) { itemsIndexed(chapters) { index, item -> ChapterCard( - chapter = item, + name = item.name, + position = item.position, + imageUrl = item.imageUrl, 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 deleted file mode 100644 index 20cab060..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ /dev/null @@ -1,324 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.CircleShape -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.layout.ContentScale -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -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.R -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.ui.AppColors -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import kotlinx.coroutines.delay - -@Composable -fun DiscoverItemCard( - item: DiscoverItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - showOverlay: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - width: Dp = Cards.height2x3 * AspectRatios.TALL, -) { - val focused by interactionSource.collectIsFocusedAsState() - 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 - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.size(width, Dp.Unspecified), - ) { - Card( - modifier = - Modifier - .size(width, Dp.Unspecified) - .aspectRatio(AspectRatios.TALL), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - modifier = - Modifier - .fillMaxSize(), - ) { - ItemCardImage( - imageUrl = item?.posterUrl, - name = item?.title, - showOverlay = false, - favorite = false, - watched = false, - unwatchedCount = 0, - watchedPercent = null, - numberOfVersions = -1, - useFallbackText = false, - contentScale = ContentScale.FillBounds, - modifier = - Modifier - .fillMaxSize(), - ) - when (item?.availability) { - SeerrAvailability.PENDING, - SeerrAvailability.PROCESSING, - -> { - PendingIndicator(Modifier.align(Alignment.TopEnd)) - } - - SeerrAvailability.PARTIALLY_AVAILABLE -> { - PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd)) - } - - SeerrAvailability.AVAILABLE, - -> { - AvailableIndicator(Modifier.align(Alignment.TopEnd)) - } - - SeerrAvailability.BLOCKLISTED -> { - // TODO handle block listed -// BlocklistedIndicator(Modifier.align(Alignment.TopEnd)) - } - - SeerrAvailability.UNKNOWN, - SeerrAvailability.DELETED, - null, - -> { - } - } - if (showOverlay) { - val color = - remember(item?.type) { - when (item?.type) { - SeerrItemType.MOVIE -> AppColors.Discover.Blue - SeerrItemType.TV -> AppColors.Discover.Purple - SeerrItemType.PERSON -> AppColors.Discover.Green - SeerrItemType.UNKNOWN -> Color.Black - null -> Color.Black - }.copy(alpha = .8f) - } - val text = - remember(item?.type) { - when (item?.type) { - SeerrItemType.MOVIE -> R.plurals.movies - SeerrItemType.TV -> R.plurals.tv_shows - SeerrItemType.PERSON -> R.plurals.people - SeerrItemType.UNKNOWN -> null - null -> null - } - } - text?.let { - Text( - text = pluralStringResource(it, 1), - style = MaterialTheme.typography.bodySmall, - fontSize = 10.sp, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(4.dp) - .background( - color = color, - shape = CircleShape, - ).padding(4.dp), - ) - } - } - } - } - SlidingCardText(focused) { - Text( - text = item?.title ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.releaseDate?.year?.toString() ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } -} - -@Composable -fun PendingIndicator(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(4.dp) - .border( - width = .5.dp, - color = AppColors.Discover.Yellow, - shape = CircleShape, - ).background( - color = Color.White.copy(alpha = .85f), - shape = CircleShape, - ).size(16.dp), - ) { - Text( - text = stringResource(R.string.fa_bell), - fontFamily = FontAwesome, - fontSize = 10.sp, - color = AppColors.Discover.Yellow, - modifier = Modifier.align(Alignment.Center), - ) - } -} - -@Composable -fun AvailableIndicator(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(4.dp) - .border( - width = .5.dp, - color = Color.White, - shape = CircleShape, - ).background( - color = AppColors.Discover.Green.copy(alpha = .85f), - shape = CircleShape, - ).size(16.dp), - ) { - Text( - text = stringResource(R.string.fa_check), - fontFamily = FontAwesome, - fontSize = 10.sp, - color = Color.White, - modifier = Modifier.align(Alignment.Center), - ) - } -} - -@Composable -fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(4.dp) - .border( - width = .5.dp, - color = Color.White, - shape = CircleShape, - ).background( - color = AppColors.Discover.Green.copy(alpha = .85f), - shape = CircleShape, - ).size(16.dp), - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .size(width = 10.dp, height = 2.dp) - .background( - color = Color.White, - shape = CircleShape, - ), - ) - } -} - -@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() { - WholphinTheme { - Column { - 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 new file mode 100644 index 00000000..6d25aa80 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -0,0 +1,150 @@ +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.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.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, + 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 = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL + val width = imageHeight * aspectRatio + val height = imageWidth * (1f / aspectRatio) + 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( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = false, + favorite = dto?.userData?.isFavorite ?: false, + watched = dto?.userData?.played ?: false, + unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, + watchedPercent = dto?.userData?.playedPercentage, + useFallbackText = false, + 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, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.name ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 238adaf8..fcfba675 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 @@ -2,10 +2,13 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.pluralRes import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards @@ -16,29 +19,42 @@ fun ExtrasRow( onLongClickItem: (Int, ExtrasItem) -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val resources = LocalResources.current ItemRow( - title = stringResource(R.string.extras_title), + title = stringResource(R.string.extras), items = extras, onClickItem = onClickItem, onLongClickItem = onLongClickItem, cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( - title = item?.title, + title = + when (item) { + is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) } + is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) } + null -> null + }, name = null, - subtitle = item?.subtitle, + subtitle = + if (item is ExtrasItem.Single && item.title != null) { + item.type.pluralRes.let { resources.getQuantityString(it, 1) } + } else if (item is ExtrasItem.Group) { + resources.getQuantityString(R.plurals.items, item.items.size, item.items.size) + } else { + null + }, onClick = onClick, onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.heightEpisode, + imageHeight = Cards.height2x3 * .75f, imageWidth = Dp.Unspecified, imageUrl = item?.imageUrl, isFavorite = false, - isPlayed = item?.isPlayed == true, + isPlayed = false, unplayedItemCount = -1, - playedPercentage = item?.playedPercentage ?: 0.0, - numberOfVersions = -1, - aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, + playedPercentage = -1.0, + aspectRatio = AspectRatios.FOUR_THREE, // TODO ) }, 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 deleted file mode 100644 index 654999f4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -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.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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 coil3.compose.AsyncImage -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.setup.rememberIdColor -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import java.util.UUID - -@Composable -fun GenreCard( - genre: Genre?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) = GenreCard( - genreId = genre?.id, - name = genre?.name, - imageUrl = genre?.imageUrl, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = interactionSource, -) - -@Composable -fun GenreCard( - genreId: UUID?, - name: String?, - imageUrl: String?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val background = rememberIdColor(genreId).copy(alpha = .6f) - Card( - modifier = modifier, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize() - .clip(RoundedCornerShape(8.dp)), - ) { - if (imageUrl != null) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), - contentScale = ContentScale.FillBounds, - contentDescription = null, - modifier = - Modifier - .alpha(.75f) - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize(), - ) - } - Box( - modifier = - Modifier - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize() - .background(background), - ) { - Text( - text = name ?: "", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - modifier = - Modifier - .padding(16.dp) - .align(Alignment.Center), - ) - } - } - } -} - -@PreviewTvSpec -@Composable -private fun GenreCardPreview() { - WholphinTheme { - val genre = - Genre( - UUID.randomUUID(), - "Adventure", - null, - ) - GenreCard( - genre = genre, - onClick = {}, - onLongClick = {}, - modifier = Modifier.width(180.dp), - ) - } -} 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 f49f2d0b..66377a3c 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -19,7 +18,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -29,7 +27,6 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.enableMarquee import kotlinx.coroutines.delay @@ -45,13 +42,11 @@ fun GridCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageAspectRatio: Float = AspectRatios.TALL, imageContentScale: ContentScale = ContentScale.Fit, - imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, - showTitle: Boolean = true, - fillWidth: Int? = null, - fillHeight: Int? = null, ) { 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 @@ -69,7 +64,7 @@ fun GridCard( } Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier, ) { Card( @@ -83,8 +78,7 @@ fun GridCard( ), ) { ItemCardImage( - item = item, - imageType = imageType.imageType, + imageUrl = item?.imageUrl, name = item?.name, // showOverlay = !focusedAfterDelay, showOverlay = true, @@ -92,46 +86,43 @@ fun GridCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, - numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, - fillWidth = fillWidth, - fillHeight = fillHeight, modifier = Modifier .fillMaxWidth() - .aspectRatio(imageAspectRatio.coerceAtLeast(AspectRatios.MIN)) + .aspectRatio(imageAspectRatio) .background(MaterialTheme.colorScheme.surfaceVariant), ) } - AnimatedVisibility(showTitle) { - SlidingCardText(focused) { - Text( - text = item?.title ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - Text( - text = item?.subtitle ?: "", - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = item?.title ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.subtitle ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index aaa3de97..86296197 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -1,10 +1,12 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -27,73 +29,17 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.logCoilError -import org.jellyfin.sdk.model.api.ImageType - -/** - * Display an image for an item with optional overlay data - * - * This will fetch the image using fillWidth/fillHeight based on the layout size - */ -@Composable -fun ItemCardImage( - item: BaseItem?, - name: String?, - showOverlay: Boolean, - favorite: Boolean, - watched: Boolean, - unwatchedCount: Int, - watchedPercent: Double?, - numberOfVersions: Int, - modifier: Modifier = Modifier, - imageType: ImageType = ImageType.PRIMARY, - useFallbackText: Boolean = true, - contentScale: ContentScale = ContentScale.Fit, - fillWidth: Int? = null, - fillHeight: Int? = null, -) { - val imageUrlService = LocalImageUrlService.current - val imageUrl = - 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( - imageUrl = imageUrl, - name = name, - showOverlay = showOverlay, - favorite = favorite, - watched = watched, - unwatchedCount = unwatchedCount, - watchedPercent = watchedPercent, - numberOfVersions = numberOfVersions, - modifier = modifier, - useFallbackText = useFallbackText, - contentScale = contentScale, - ) -} @Composable fun ItemCardImage( @@ -104,22 +50,12 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, - numberOfVersions: Int, modifier: Modifier = Modifier, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, - fallback: @Composable BoxScope.() -> Unit = { - ItemCardImageFallback( - name = name, - useFallbackText = useFallbackText, - modifier = Modifier, - ) - }, ) { - var imageError by remember(imageUrl) { mutableStateOf(false) } - Box( - modifier = modifier, - ) { + var imageError by remember { mutableStateOf(false) } + Box(modifier = modifier) { if (!imageError && imageUrl.isNotNullOrBlank()) { AsyncImage( model = imageUrl, @@ -136,158 +72,104 @@ fun ItemCardImage( .align(Alignment.TopCenter), ) } else { - fallback.invoke(this) - } - if (showOverlay) { - ItemCardImageOverlay( - favorite = favorite, - watched = watched, - unwatchedCount = unwatchedCount, - watchedPercent = watchedPercent, - numberOfVersions = numberOfVersions, - modifier = Modifier, - ) - } - } -} - -@Composable -fun BoxScope.ItemCardImageFallback( - name: String?, - useFallbackText: Boolean, - modifier: Modifier = Modifier, -) { - // TODO options for overriding fallback - Box( - modifier = - modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - .fillMaxSize() - .align(Alignment.TopCenter), - ) { - if (useFallbackText && name.isNotNullOrBlank()) { - Text( - text = name, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp, - modifier = - Modifier - .padding(8.dp) - .align(Alignment.Center), - ) - } else { - Image( - painter = painterResource(id = R.drawable.video_solid), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = - ColorFilter.tint( - MaterialTheme.colorScheme.onSurfaceVariant, - BlendMode.SrcIn, - ), - modifier = - Modifier - .fillMaxSize(.4f) - .align(Alignment.Center), - ) - } - } -} - -@Composable -fun ItemCardImageOverlay( - favorite: Boolean, - watched: Boolean, - unwatchedCount: Int, - watchedPercent: Double?, - numberOfVersions: Int, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier.fillMaxSize()) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .align(Alignment.TopStart) - .padding(4.dp), - ) { - if (numberOfVersions > 1) { - Box( - modifier = - Modifier - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(25), - ), - ) { - Text( - text = numberOfVersions.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, -// fontSize = 16.sp, - modifier = Modifier.padding(4.dp), - ) - } - } - if (favorite) { - FavoriteIndicator() - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(4.dp) - .align(Alignment.TopEnd), - ) { - if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) { - WatchedIcon(Modifier.size(24.dp)) - } - if (unwatchedCount > 0) { - Box( - modifier = - Modifier - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(25), - ), - ) { - Text( - text = unwatchedCount.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, -// fontSize = 16.sp, - modifier = Modifier.padding(4.dp), - ) - } - } - } - - if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) { + // TODO options for overriding fallback Box( modifier = Modifier - .align(Alignment.BottomStart) - .background( - MaterialTheme.colorScheme.tertiary, - ).clip(RectangleShape) - .height(Cards.playedPercentHeight) - .fillMaxWidth((watchedPercent / 100.0).toFloat()), - ) + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxSize() + .align(Alignment.TopCenter), + ) { + if (useFallbackText && name.isNotNullOrBlank()) { + Text( + text = name, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = + Modifier + .padding(8.dp) + .align(Alignment.Center), + ) + } else { + Image( + painter = painterResource(id = R.drawable.video_solid), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = + ColorFilter.tint( + MaterialTheme.colorScheme.onSurfaceVariant, + BlendMode.SrcIn, + ), + modifier = + Modifier + .fillMaxSize(.4f) + .align(Alignment.Center), + ) + } + } + } + AnimatedVisibility( + visible = showOverlay, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (favorite) { + Text( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(8.dp), + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(4.dp) + .align(Alignment.TopEnd), + ) { + if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) { + WatchedIcon(Modifier.size(24.dp)) + } + if (unwatchedCount > 0) { + Box( + modifier = + Modifier + .background( + AppColors.TransparentBlack50, + shape = RoundedCornerShape(25), + ), + ) { + Text( + text = unwatchedCount.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, +// fontSize = 16.sp, + modifier = Modifier.padding(4.dp), + ) + } + } + } + + watchedPercent?.let { percent -> + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background( + MaterialTheme.colorScheme.tertiary, + ).clip(RectangleShape) + .height(Cards.playedPercentHeight) + .fillMaxWidth((percent / 100.0).toFloat()), + ) + } + } } } } - -@Composable -fun FavoriteIndicator( - modifier: Modifier = Modifier, - fontSize: TextUnit = 20.sp, -) = Text( - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = fontSize, - fontFamily = FontAwesome, - modifier = modifier, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 6544895b..e52fdd0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -1,31 +1,29 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.util.FocusPair @Composable fun <T> ItemRow( @@ -41,29 +39,22 @@ fun <T> ItemRow( onLongClick: () -> Unit, ) -> Unit, modifier: Modifier = Modifier, + focusPair: FocusPair? = null, + cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, horizontalPadding: Dp = 16.dp, - showViewMore: Boolean = false, - viewMoreCardContent: @Composable (Modifier) -> Unit = {}, ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } - val focusRequester = remember { FocusRequester() } - var position by rememberInt() - - val currentOnClickItem by rememberUpdatedState(onClickItem) - val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) - + var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) } Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier.focusProperties { - onEnter = { - focusRequester.tryRequestFocus() - } - }, + modifier = modifier, ) { - ItemRowTitle(title) - + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(horizontalPadding), @@ -71,69 +62,68 @@ fun <T> ItemRow( modifier = Modifier .fillMaxWidth() - .focusGroup() - .focusRestorer(firstFocus) - .focusRequester(focusRequester), + .focusRestorer(focusPair?.focusRequester ?: firstFocus), ) { itemsIndexed(items) { index, item -> val cardModifier = - remember(index, position) { - if (index == position) { - Modifier.focusRequester(firstFocus) + if (index == 0 && focusPair == null) { + Modifier.focusRequester(firstFocus) + } else { + if (focusPair != null && focusPair.column == index) { + Modifier.focusRequester(focusPair.focusRequester) } else { Modifier } - } - - val onClick = - remember(index, item) { - { - position = index - if (item != null) currentOnClickItem(index, item) + }.onFocusChanged { + if (it.isFocused) { + focusedIndex = index } + cardOnFocus?.invoke(it.isFocused, index) } - - val onLongClick = - remember(index, item) { - { - position = index - if (item != null) currentOnLongClickItem(index, item) - } - } - cardContent.invoke( index, item, cardModifier, - onClick, - onLongClick, + { if (item != null) onClickItem.invoke(index, item) }, + { if (item != null) onLongClickItem.invoke(index, item) }, ) } - if (showViewMore) { - item { - val cardModifier = - remember(items.size, position) { - if (position == items.size) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - } - } - viewMoreCardContent.invoke(cardModifier) - } - } } } } @Composable -@NonRestartableComposable -fun ItemRowTitle( +fun BannerItemRow( title: String, + items: List<BaseItem?>, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, -) = Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = modifier.padding(start = 8.dp), + focusPair: FocusPair? = null, + cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, + aspectRatioOverride: Float? = null, +) = ItemRow( + title = title, + items = items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = modifier, + cardContent = { index, item, modifier, onClick, onLongClick -> + BannerCard( + name = title, + imageUrl = item?.imageUrl, + aspectRatio = + aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() + ?: AspectRatios.WIDE, + cornerText = item?.data?.indexNumber?.let { "E$it" }, + played = item?.data?.userData?.played ?: false, + playPercent = item?.data?.userData?.playedPercentage ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = null, + ) + }, + focusPair = focusPair, + cardOnFocus = cardOnFocus, ) 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 3154d257..83d72010 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,77 +1,37 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background +import androidx.compose.animation.core.animateDpAsState 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.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.Border 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.R import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlinx.coroutines.delay -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.PersonKind /** * A Card for a [Person] such as an actor or director */ @Composable fun PersonCard( - person: Person, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) = PersonCard( - name = person.name, - role = person.role, - imageUrl = person.imageUrl, - favorite = person.favorite, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = interactionSource, -) - -@Composable -fun PersonCard( - name: String?, - role: String?, - imageUrl: String?, - favorite: Boolean, + item: Person, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -79,7 +39,7 @@ fun PersonCard( ) { val hideOverlayDelay = 1_000L - val focused by interactionSource.collectIsFocusedAsState() + val focused = interactionSource.collectIsFocusedAsState().value var focusedAfterDelay by remember { mutableStateOf(false) } if (focused) { @@ -94,9 +54,10 @@ fun PersonCard( } else { focusedAfterDelay = false } - + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) Column( - verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing + verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier, ) { Card( @@ -104,84 +65,48 @@ fun PersonCard( onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, - shape = CardDefaults.shape(CircleShape), - border = - CardDefaults.border( - focusedBorder = - Border( - border = - BorderStroke( - width = 3.dp, - color = MaterialTheme.colorScheme.border, - ), - shape = CircleShape, - ), - ), colors = CardDefaults.colors( containerColor = Color.Transparent, ), ) { ItemCardImage( - imageUrl = imageUrl, - name = name, - showOverlay = false, - favorite = favorite, + imageUrl = item.imageUrl, + name = item.name, + showOverlay = true, + favorite = item.favorite, watched = false, unwatchedCount = -1, - numberOfVersions = -1, watchedPercent = null, - useFallbackText = true, - contentScale = ContentScale.Crop, - fallback = { - Box( - modifier = - modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - .fillMaxSize() - .align(Alignment.Center), - ) { - Text( - text = stringResource(R.string.fa_user), - fontFamily = FontAwesome, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 64.sp, - textAlign = TextAlign.Center, - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth() - .align(Alignment.Center), - ) - } - }, + useFallbackText = false, modifier = Modifier .fillMaxWidth() - .aspectRatio(AspectRatios.SQUARE) - .clip(CircleShape), + .aspectRatio(AspectRatios.TALL), // TODO, ) } - SlidingCardText(focused) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { Text( - text = name ?: "", + text = item.name ?: "", maxLines = 1, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() .padding(horizontal = 4.dp) .enableMarquee(focusedAfterDelay), ) - role?.let { + item.role?.let { Text( - text = role, + text = item.role, maxLines = 1, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() @@ -192,24 +117,3 @@ fun PersonCard( } } } - -@PreviewTvSpec -@Composable -private fun PersonCardPreview() { - WholphinTheme { - PersonCard( - person = - Person( - id = UUID.randomUUID(), - name = "John Smith", - role = "Actor", - type = PersonKind.ACTOR, - imageUrl = null, - favorite = false, - ), - onClick = {}, - onLongClick = {}, - modifier = Modifier.width(personRowCardWidth), - ) - } -} 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 637aa5cf..6504a13f 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,9 +10,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -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.focus.focusRequester @@ -23,77 +20,23 @@ 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.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.rememberInt @Composable fun PersonRow( people: List<Person>, onClick: (Person) -> Unit, modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people_title, onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } - var position by rememberInt() Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Text( - text = stringResource(title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), - ) - LazyRow( - state = rememberLazyListState(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(firstFocus), - ) { - itemsIndexed(people) { index, person -> - PersonCard( - person = person, - onClick = { - position = index - onClick.invoke(person) - }, - onLongClick = { - position = index - onLongClick?.invoke(index, person) - }, - modifier = - Modifier - .width(personRowCardWidth) - .ifElse(index == position, Modifier.focusRequester(firstFocus)) - .animateItem(), - ) - } - } - } -} - -@Composable -fun DiscoverPersonRow( - people: List<DiscoverItem>, - onClick: (DiscoverItem) -> Unit, - modifier: Modifier = Modifier, - @StringRes title: Int = R.string.people_title, - onLongClick: ((Int, DiscoverItem) -> Unit)? = null, -) { - val firstFocus = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(title), + text = stringResource(R.string.people), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -107,23 +50,17 @@ fun DiscoverPersonRow( .fillMaxWidth() .focusRestorer(firstFocus), ) { - itemsIndexed(people) { index, person -> + itemsIndexed(people) { index, item -> PersonCard( - name = person.title, - role = person.subtitle, - imageUrl = person.posterUrl, - favorite = false, - onClick = { onClick.invoke(person) }, - onLongClick = { onLongClick?.invoke(index, person) }, + item = item, + onClick = { onClick.invoke(item) }, + onLongClick = { onLongClick?.invoke(index, item) }, modifier = Modifier - .width(personRowCardWidth) - .ifElse(index == 0, Modifier.focusRequester(firstFocus)) - .animateItem(), + .width(120.dp) + .ifElse(index == 0, Modifier.focusRequester(firstFocus)), ) } } } } - -val personRowCardWidth = 108.dp 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 1992cab4..ed9d01cd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -16,103 +16,52 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.layout -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.compose.ui.unit.isSpecified import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.enableMarquee import kotlinx.coroutines.delay -import org.jellyfin.sdk.model.api.ImageType /** - * A Card for a TV Show Season, but can generally show most items + * A Card for a TV Show Season, but can generically 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 imageUrl = rememberImageUrl(item, imageHeight, imageWidth) - SeasonCard( - title = item?.title, - subtitle = item?.subtitle, - name = item?.name, - imageUrl = imageUrl, - isFavorite = item?.data?.userData?.isFavorite ?: false, - isPlayed = item?.data?.userData?.played ?: false, - unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, - playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - imageHeight = imageHeight, - imageWidth = imageWidth, - interactionSource = interactionSource, - showImageOverlay = showImageOverlay, - aspectRatio = aspectRatio, - ) -} - -@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 + aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL, +) = SeasonCard( + title = item?.title, + subtitle = item?.subtitle, + name = item?.name, + imageUrl = item?.imageUrl, + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = imageHeight, + imageWidth = imageWidth, + interactionSource = interactionSource, + showImageOverlay = showImageOverlay, + aspectRatio = aspectRatio, +) /** * A Card for a TV Show Season, but can generically show most items @@ -127,7 +76,6 @@ fun SeasonCard( isPlayed: Boolean, unplayedItemCount: Int, playedPercentage: Double, - numberOfVersions: Int, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -138,27 +86,34 @@ fun SeasonCard( aspectRatio: Float = AspectRatios.TALL, ) { 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) spaceBetweenFocused else spaceBetweenUnfocused, - label = "spaceBetween", - ) - - val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) - val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) - val width = imageHeight * aspectRationToUse - val height = imageWidth * (1f / aspectRationToUse) + 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 width = imageHeight * aspectRatio + val height = imageWidth * (1f / aspectRatio) Column( - verticalArrangement = Arrangement.spacedBy(4.dp), // Fixed base spacing + verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier.size(width, height), ) { Card( modifier = Modifier .size(imageWidth, imageHeight) - .aspectRatio(aspectRationToUse), + .aspectRatio(aspectRatio), onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -180,7 +135,6 @@ fun SeasonCard( watched = isPlayed, unwatchedCount = unplayedItemCount, watchedPercent = playedPercentage, - numberOfVersions = numberOfVersions, useFallbackText = false, modifier = Modifier @@ -192,21 +146,13 @@ fun SeasonCard( 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(), + .padding(bottom = spaceBelow) + .fillMaxWidth(), ) { Text( text = title ?: "", maxLines = 1, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -217,8 +163,6 @@ fun SeasonCard( text = subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() @@ -228,22 +172,3 @@ fun SeasonCard( } } } - -/** - * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while - */ -@Composable -fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> { - val focused by interactionSource.collectIsFocusedAsState() - val state = remember { mutableStateOf(false) } - - LaunchedEffect(focused) { - if (!focused) { - state.value = false - return@LaunchedEffect - } - delay(500L) - state.value = true - } - return state -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt deleted file mode 100644 index 90d99281..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SlidingCardText.kt +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index c128285d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -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.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -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 coil3.compose.AsyncImage -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.Studio -import com.github.damontecres.wholphin.ui.setup.rememberIdColor -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import java.util.UUID - -@Composable -fun StudioCard( - studio: Studio?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) = StudioCard( - studioId = studio?.id, - name = studio?.name, - imageUrl = studio?.imageUrl, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = interactionSource, -) - -@Composable -fun StudioCard( - studioId: UUID?, - name: String?, - imageUrl: String?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val background = rememberIdColor(studioId).copy(alpha = .4f) - var error by remember { mutableStateOf(false) } - Card( - modifier = modifier, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize() - .clip(RoundedCornerShape(8.dp)), - ) { - if (imageUrl != null && !error) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageUrl) - .crossfade(true) - .build(), - contentScale = ContentScale.FillBounds, - contentDescription = null, - onError = { - error = true - }, - modifier = - Modifier - .alpha(.75f) - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize(), - ) - } else { - Box( - modifier = - Modifier - .aspectRatio(AspectRatios.WIDE) - .fillMaxSize() - .background(background), - ) { - Text( - text = name ?: "", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - modifier = - Modifier - .padding(16.dp) - .align(Alignment.Center), - ) - } - } - } - } -} - -@PreviewTvSpec -@Composable -private fun GenreCardPreview() { - WholphinTheme { - val studio = - Studio( - UUID.randomUUID(), - "Adventure", - null, - ) - StudioCard( - studio = studio, - onClick = {}, - onLongClick = {}, - modifier = Modifier.width(180.dp), - ) - } -} 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 deleted file mode 100644 index fa7df8d6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ViewMoreCard.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.github.damontecres.wholphin.ui.cards - -import androidx.compose.animation.core.animateDpAsState -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForward -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -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.Icon -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.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import kotlinx.coroutines.delay - -@Composable -fun ViewMoreCard( - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - aspectRatio: AspectRatio = AspectRatio.TALL, - size: DpSize = DpSize(width = Cards.height2x3 * aspectRatio.ratio, height = Cards.height2x3), - showTitle: Boolean = true, -) { - val focused by interactionSource.collectIsFocusedAsState() - 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 width = - remember(size, aspectRatio) { - size.width.takeIf { it.isSpecified } ?: (size.height * aspectRatio.ratio) - } - val height = - remember(size, aspectRatio) { - size.height.takeIf { it.isSpecified } ?: (size.height * (1f / aspectRatio.ratio)) - } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - Card( - modifier = - Modifier - .size(width, height) - .aspectRatio(aspectRatio.ratio), - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - ), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), - ) { - Icon( - imageVector = Icons.Default.ArrowForward, - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = "View more", - modifier = Modifier.fillMaxSize(.66f), - ) - } - } - if (showTitle) { - SlidingCardText(focused) { - Text( - text = stringResource(R.string.view_more), - maxLines = 1, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = - Modifier - .width(width) - .padding(horizontal = 4.dp) - .enableMarquee(focusedAfterDelay), - ) - } - } - } -} - -@PreviewTvSpec -@Composable -private fun Preview() { - WholphinTheme { - Column { - ViewMoreCard( - onClick = {}, - onLongClick = {}, - modifier = Modifier.padding(16.dp), - aspectRatio = AspectRatio.TALL, - size = DpSize(width = Dp.Unspecified, height = Cards.heightEpisode), - showTitle = false, - ) - } - } -} 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 ce8952a7..cbec7d53 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 @@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable 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.Icon import androidx.tv.material3.MaterialTheme @@ -20,10 +19,7 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme @Composable -fun WatchedIcon( - modifier: Modifier = Modifier, - padding: Dp = 2.dp, -) { +fun WatchedIcon(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -32,7 +28,7 @@ fun WatchedIcon( modifier .background(WatchedIconBackground(), shape = CircleShape) .border(.5.dp, Color.Black, CircleShape) - .padding(padding), + .padding(2.dp), ) } @@ -45,8 +41,6 @@ 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 @@ -62,8 +56,6 @@ 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/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt deleted file mode 100644 index 8aa43946..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt +++ /dev/null @@ -1,302 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.center -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RadialGradientShader -import androidx.compose.ui.graphics.Shader -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.datastore.core.DataStore -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.AsyncImage -import coil3.compose.useExistingImageAsPlaceholder -import coil3.request.ImageRequest -import coil3.request.transitionFactory -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.CrossFadeFactory -import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA -import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -@HiltViewModel -class ScreensaverViewModel - @Inject - constructor( - private val screensaverService: ScreensaverService, - val preferencesDataStore: DataStore<AppPreferences>, - ) : ViewModel() { - val currentItem = screensaverService.createItemFlow(viewModelScope) - } - -sealed interface ScreensaverItem { - data class Error( - val exception: Exception, - ) : ScreensaverItem - - data object Empty : ScreensaverItem - - data class CurrentItem( - val item: BaseItem, - val backdropUrl: String, - val logoUrl: String?, - val title: String, - ) : ScreensaverItem -} - -@Composable -fun AppScreensaver( - prefs: AppPreferences, - modifier: Modifier = Modifier, - viewModel: ScreensaverViewModel = hiltViewModel(), -) { - val currentItem by viewModel.currentItem.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = prefs.interfacePreferences.screensaverPreference.showClock, - duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, - animate = prefs.interfacePreferences.screensaverPreference.animate, - modifier = modifier, - ) -} - -@OptIn(ExperimentalCoilApi::class) -@Composable -fun AppScreensaverContent( - currentItem: ScreensaverItem?, - showClock: Boolean, - duration: Duration, - animate: Boolean, - modifier: Modifier = Modifier, -) { - Box( - modifier - .background(Color.Black), - ) { - val infiniteTransition = rememberInfiniteTransition() - val scale by infiniteTransition.animateFloat( - 1f, - if (animate) 1.1f else 1f, - infiniteRepeatable( - tween( - durationMillis = duration.inWholeMilliseconds.toInt(), - delayMillis = 500, - easing = LinearEasing, - ), - repeatMode = RepeatMode.Reverse, - ), - ) - when (currentItem) { - ScreensaverItem.Empty -> { - ScreensaverPlaceholder( - text = stringResource(R.string.no_results), - duration = duration, - modifier = Modifier.fillMaxSize(), - ) - } - - is ScreensaverItem.Error -> { - ScreensaverPlaceholder( - text = "Error connecting to Jellyfin server", - duration = duration, - modifier = Modifier.fillMaxSize(), - ) - } - - null, - is ScreensaverItem.CurrentItem, - -> { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(currentItem?.backdropUrl) - .transitionFactory(CrossFadeFactory(2000.milliseconds)) - .useExistingImageAsPlaceholder(true) - .build(), - contentDescription = null, - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = scale - scaleY = scale - }, - ) - - var logoError by remember(currentItem) { mutableStateOf(false) } - if (!logoError) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(currentItem?.logoUrl) - .transitionFactory(CrossFadeFactory(750.milliseconds)) - .build(), - contentDescription = "Logo", - onError = { - logoError = true - }, - modifier = - Modifier - .align(Alignment.BottomStart) - .size(width = 240.dp, height = 120.dp) - .padding(16.dp), - ) - } else { - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(16.dp) - .fillMaxWidth(.5f) - .fillMaxHeight(.3f), - ) { - Text( - text = currentItem?.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomStart), - ) - } - } - } - } - - val largeRadialGradient = - remember { - object : ShaderBrush() { - override fun createShader(size: Size): Shader { - val biggerDimension = maxOf(size.height, size.width) - return RadialGradientShader( - colors = listOf(Color.Transparent, AppColors.TransparentBlack25), - center = size.center, - radius = biggerDimension / 1.5f, - colorStops = listOf(0f, 0.85f), - ) - } - } - } - Canvas(Modifier.fillMaxSize()) { - drawRect( - brush = largeRadialGradient, - blendMode = BlendMode.Multiply, - ) - if (showClock) { - // Add scrim to make clock more readable - drawRect( - brush = - Brush.verticalGradient( - colorStops = - arrayOf( - 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), - TOP_SCRIM_END_FRACTION to Color.Transparent, - ), - ), - blendMode = BlendMode.Multiply, - ) - } - } - if (showClock) { - TimeDisplay() - } - } -} - -@Composable -fun ScreensaverPlaceholder( - text: String, - duration: Duration, - modifier: Modifier = Modifier, -) { - var alignment by remember { mutableStateOf(Alignment.BottomStart) } - val alignments = - remember(alignment) { - mutableListOf( - Alignment.TopStart, - Alignment.TopCenter, - Alignment.TopEnd, - Alignment.CenterStart, - Alignment.Center, - Alignment.CenterEnd, - Alignment.BottomStart, - Alignment.BottomCenter, - Alignment.BottomEnd, - ).apply { remove(alignment) } - } - LaunchedEffect(Unit) { - while (true) { - delay(duration) - alignment = alignments.random() - } - } - AnimatedContent( - targetState = alignment, - label = "alignment animation", - transitionSpec = { - fadeIn(animationSpec = tween(500, delayMillis = 100)) - .togetherWith(fadeOut(animationSpec = tween(500, 100))) - }, - modifier = modifier, - ) { align -> - Box(Modifier.fillMaxSize()) { - Text( - text = text, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.error, - modifier = - Modifier - .align(align) - .padding(40.dp), - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt deleted file mode 100644 index ff85886d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Button.kt +++ /dev/null @@ -1,178 +0,0 @@ -// This file was inspired by related Button source files from -// https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-main/tv/tv-material/src/main/java/androidx/tv/material3 - -package com.github.damontecres.wholphin.ui.components - -import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceBorder -import androidx.tv.material3.ClickableSurfaceColors -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ClickableSurfaceGlow -import androidx.tv.material3.ClickableSurfaceScale -import androidx.tv.material3.ClickableSurfaceShape -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ProvideTextStyle -import androidx.tv.material3.Surface -import androidx.tv.material3.Text - -/** - * This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc - * - * This allows for creating smaller and fully circular Buttons. - */ -@Composable -fun Button( - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - enabled: Boolean = true, - scale: ClickableSurfaceScale = ClickableSurfaceDefaults.scale(), - glow: ClickableSurfaceGlow = ClickableSurfaceDefaults.glow(), - shape: ClickableSurfaceShape = - ClickableSurfaceDefaults.shape( - shape = CircleShape, - ), - colors: ClickableSurfaceColors = - ClickableSurfaceDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), - contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - focusedContainerColor = MaterialTheme.colorScheme.onSurface, - focusedContentColor = MaterialTheme.colorScheme.inverseOnSurface, - pressedContainerColor = MaterialTheme.colorScheme.onSurface, - pressedContentColor = MaterialTheme.colorScheme.inverseOnSurface, - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), - ), - tonalElevation: Dp = 0.dp, - border: ClickableSurfaceBorder = - ClickableSurfaceDefaults.border( - border = Border.None, - focusedBorder = Border.None, - pressedBorder = Border.None, - disabledBorder = Border.None, - focusedDisabledBorder = - Border( - border = - BorderStroke( - width = 1.5.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - ), - shape = CircleShape, - ), - ), - contentPadding: PaddingValues = DefaultButtonPadding, - contentHeight: Dp = MinButtonSize, - interactionSource: MutableInteractionSource? = null, - content: @Composable RowScope.() -> Unit, -) { - Surface( - modifier = modifier.semantics { role = Role.Button }, - onClick = onClick, - onLongClick = onLongClick, - enabled = enabled, - scale = scale, - glow = glow, - shape = shape, - colors = colors, - tonalElevation = tonalElevation, - border = border, - interactionSource = interactionSource, - ) { - ProvideTextStyle(value = MaterialTheme.typography.labelLarge) { - Row( - modifier = - Modifier - .padding(contentPadding) - .height(contentHeight), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - content = content, - ) - } - } -} - -@Composable -@NonRestartableComposable -fun TextButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - enabled: Boolean = true, - contentPadding: PaddingValues = - PaddingValues( - start = 8.dp, - top = 4.dp, - end = 8.dp, - bottom = 4.dp, - ), - contentHeight: Dp = 32.dp, - interactionSource: MutableInteractionSource? = null, - content: @Composable RowScope.() -> Unit, -) = Button( - onClick = onClick, - modifier = modifier, - onLongClick = onLongClick, - enabled = enabled, - contentPadding = contentPadding, - contentHeight = contentHeight, - interactionSource = interactionSource, - content = content, -) - -@Composable -fun TextButton( - @StringRes stringRes: Int, - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - enabled: Boolean = true, - contentPadding: PaddingValues = - PaddingValues( - start = 8.dp, - top = 4.dp, - end = 8.dp, - bottom = 4.dp, - ), - contentHeight: Dp = 32.dp, - interactionSource: MutableInteractionSource? = null, -) = Button( - onClick = onClick, - modifier = modifier, - onLongClick = onLongClick, - enabled = enabled, - contentPadding = contentPadding, - contentHeight = contentHeight, - interactionSource = interactionSource, - content = { - Text(text = stringResource(stringRes)) - }, -) - -val DefaultButtonPadding = - PaddingValues( - start = 4.dp, - top = 4.dp, - end = 4.dp, - bottom = 4.dp, - ) 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 e00a2185..b62c24c7 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,10 +7,7 @@ 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 @@ -18,9 +15,6 @@ 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) { @@ -44,9 +38,7 @@ fun LoadingPage( focusEnabled: Boolean = true, ) { val focusRequester = remember { FocusRequester() } - if (focusEnabled) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Box( contentAlignment = Alignment.Center, modifier = @@ -64,41 +56,3 @@ 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 af67f12c..fa52ff18 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 @@ -1,118 +1,770 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +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.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 import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.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.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.lifecycle.MutableLiveData +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.data.LibraryDisplayInfoDao +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.DefaultFilterOptions +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.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel 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.util.ScrollToTopBringIntoViewSpec +import com.github.damontecres.wholphin.ui.detail.ItemViewModel +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.tryRequestFocus +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.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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.yearsApi 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.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.TreeSet +import java.util.UUID +import javax.inject.Inject import kotlin.time.Duration -@OptIn(ExperimentalFoundationApi::class) +@HiltViewModel +class CollectionFolderViewModel + @Inject + constructor( + api: ApiClient, + @param:ApplicationContext private val context: Context, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + val navigationManager: NavigationManager, + ) : ItemViewModel(api) { + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading) + val pager = MutableLiveData<List<BaseItem?>>(listOf()) + val sortAndDirection = MutableLiveData<SortAndDirection>() + val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter()) + + private var useSeriesForPrimary: Boolean = true + + fun init( + itemId: String, + initialSortAndDirection: SortAndDirection?, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ): Job = + viewModelScope.launch( + LoadingExceptionHandler( + loading, + context.getString(R.string.error_loading_collection, itemId), + ) + Dispatchers.IO, + ) { + this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary + this@CollectionFolderViewModel.itemId = itemId + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } + + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + + val sortAndDirection = + initialSortAndDirection + ?: libraryDisplayInfo?.sortAndDirection + ?: SortAndDirection.DEFAULT + + val filterToUse = + if (libraryDisplayInfo?.filter != null) { + filter.merge(libraryDisplayInfo.filter) + } else { + filter + } + + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + } + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + Timber.v("onFilterChange: filter=%s", newFilter) + serverRepository.currentUser.value?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.value!!.sort, + direction = sortAndDirection.value!!.direction, + filter = newFilter, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) + } + + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + Timber.v( + "onSortChange: sort=%s, recursive=%s, filter=%s", + sortAndDirection, + recursive, + filter, + ) + serverRepository.currentUser.value?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = filter, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) + } + + private fun loadResults( + resetState: Boolean, + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ) { + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + pager.value = listOf() + loading.value = LoadingState.Loading + } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + delay(1000) + val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary) + newPager.init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + pager.value = newPager + loading.value = LoadingState.Success + backgroundLoading.value = LoadingState.Success + } + } + } + + private fun createPager( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ): ApiRequestPager<out Any> { + val item = item.value + return when (filter.override) { + GetItemsFilterOverride.NONE -> { + val includeItemTypes = + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = + buildList { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + }, + sortOrder = + buildList { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + }, + fields = SlimItemFields, + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + + GetItemsFilterOverride.PERSON -> { + val request = + filter.applyTo( + GetPersonsRequest( + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetPersonsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + } + } + + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = itemUuid, + userId = serverRepository.currentUser.value?.id, + ).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 = itemUuid, + userId = serverRepository.currentUser.value?.id, + 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 = itemUuid, + userId = serverRepository.currentUser.value?.id, + 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() + } + + suspend fun positionOfLetter(letter: Char): Int? = + withContext(Dispatchers.IO) { + item.value?.let { item -> + val includeItemTypes = + when (item.data.collectionType) { + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + + else -> listOf() + } + val request = + GetItemsRequest( + parentId = item.id, + includeItemTypes = includeItemTypes, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + recursive = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } + } + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + +/** + * Shows a collection folder as a grid + * + * This is the "Library" tab for Movies or TV shows + */ @Composable -fun CollectionFolderViewScope.CollectionFolderGrid( +fun CollectionFolderGrid( preferences: UserPreferences, - focusedItem: BaseItem?, - items: List<BaseItem?>, + itemId: UUID, + initialFilter: GetItemsFilter, + recursive: Boolean, + onClickItem: (Int, BaseItem) -> Unit, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + modifier: Modifier = Modifier, + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + params: CollectionFolderGridParameters = CollectionFolderGridParameters(), + useSeriesForPrimary: Boolean = true, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, +) = CollectionFolderGrid( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + onClickItem, + sortOptions, + playEnabled, + modifier, + initialSortAndDirection = initialSortAndDirection, + showTitle = showTitle, + positionCallback = positionCallback, + params = params, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, +) + +@Composable +fun CollectionFolderGrid( + preferences: UserPreferences, + itemId: String, + initialFilter: GetItemsFilter, + recursive: Boolean, + onClickItem: (Int, BaseItem) -> Unit, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + modifier: Modifier = Modifier, + viewModel: CollectionFolderViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + params: CollectionFolderGridParameters = CollectionFolderGridParameters(), + useSeriesForPrimary: Boolean = true, + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, +) { + val context = LocalContext.current + OneTimeLaunchedEffect { + viewModel.init( + itemId, + initialSortAndDirection, + recursive, + initialFilter, + useSeriesForPrimary, + ) + } + val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) + val filter by viewModel.filter.observeAsState(initialFilter) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) + val item by viewModel.item.observeAsState() + val pager by viewModel.pager.observeAsState() + + var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } + var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + LoadingState.Success -> { + pager?.let { pager -> + Box(modifier = modifier) { + CollectionFolderGridContent( + preferences, + item, + pager, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + onClickItem = onClickItem, + onLongClickItem = { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = positionCallback, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + params = params, + playEnabled = playEnabled, + onClickPlay = { shuffle -> + itemId.toUUIDOrNull()?.let { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ), + ) + } + }, + ) + + AnimatedVisibility( + backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) + } + } + } + } + } + moreDialog.compose { (position, item) -> + DialogPopup( + showDialog = true, + title = item.title ?: "", + dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, watched -> + viewModel.setFavorite(position, itemId, watched) + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(it) + }, + ), + ), + onDismissRequest = { moreDialog.makeAbsent() }, + dismissOnClick = true, + waitToLoad = true, + ) + } + 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, + ) + } +} + +@Composable +fun CollectionFolderGridContent( + preferences: UserPreferences, + item: BaseItem?, + pager: List<BaseItem?>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, + onSortChange: (SortAndDirection) -> Unit, letterPosition: suspend (Char) -> Int, - onClickPlay: (Int, BaseItem) -> Unit, - initialPosition: Int, - gridFocusRequester: FocusRequester, - cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, + sortOptions: List<ItemSortBy>, + playEnabled: Boolean, + onClickPlay: (shuffle: Boolean) -> Unit, modifier: Modifier = Modifier, + showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + params: CollectionFolderGridParameters = CollectionFolderGridParameters(), + currentFilter: GetItemsFilter = GetItemsFilter(), + filterOptions: List<ItemFilterBy<*>> = listOf(), + onFilterChange: (GetItemsFilter) -> Unit = {}, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, ) { - Box(modifier = modifier) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.fillMaxSize(), + val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) + + var showHeader by rememberSaveable { mutableStateOf(true) } + + val gridFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), ) { - val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current - val density = LocalDensity.current - AnimatedVisibility(viewOptions.showDetails) { - HomePageHeader( - item = focusedItem, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + val endPadding = + 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .padding(HeaderUtils.padding), - ) - } - CardGrid( - pager = items, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = Modifier.fillMaxSize(), - initialPosition = initialPosition, - positionCallback = positionCallback, - cardContent = { details -> - cardContent.invoke(this@CollectionFolderGrid, details) - }, - columns = viewOptions.columns, - spacing = viewOptions.spacing.dp, - bringIntoViewSpec = - remember(viewOptions, density) { - val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } - if (viewOptions.showDetails) { - ScrollToTopBringIntoViewSpec(spacingPx) - } else { - defaultBringIntoViewSpec + .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, + ) + } } - }, - ) + } + if (playEnabled) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlay.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlay.invoke(true) }, + ) + } + } + } + } } + CardGrid( + pager = pager, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = Modifier.fillMaxSize(), + initialPosition = 0, + positionCallback = { columns, position -> + showHeader = position < columns + positionCallback?.invoke(columns, position) + }, + cardContent = params.cardContent, + columns = params.columns, + spacing = params.spacing, + ) } } @@ -124,13 +776,17 @@ data class PositionItem( data class CollectionFolderGridParameters( val columns: Int = 6, val spacing: Dp = 16.dp, - val cardContent: @Composable (GridItemDetails<BaseItem>) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> + val cardContent: @Composable ( + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + mod: Modifier, + ) -> Unit = { item, onClick, onLongClick, mod -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, - fillWidth = widthPx, modifier = mod, ) }, @@ -140,14 +796,13 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + cardContent = { item, onClick, onLongClick, mod -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.TALL, - fillWidth = widthPx, modifier = mod, ) }, @@ -156,14 +811,13 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 4, spacing = 24.dp, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + cardContent = { item, onClick, onLongClick, mod -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.Crop, imageAspectRatio = AspectRatios.WIDE, - fillWidth = widthPx, modifier = mod, ) }, @@ -172,14 +826,13 @@ data class CollectionFolderGridParameters( CollectionFolderGridParameters( columns = 6, spacing = 16.dp, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> + cardContent = { item, onClick, onLongClick, mod -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, imageAspectRatio = AspectRatios.SQUARE, - fillWidth = widthPx, modifier = mod, ) }, @@ -190,209 +843,18 @@ data class CollectionFolderGridParameters( val CollectionType.baseItemKinds: List<BaseItemKind> get() = when (this) { - CollectionType.MOVIES -> { - listOf(BaseItemKind.MOVIE) - } - - CollectionType.TVSHOWS -> { - listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) - } - - CollectionType.PHOTOS, - CollectionType.HOMEVIDEOS, - -> { - listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) - } - - CollectionType.MUSIC -> { + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + CollectionType.MUSIC -> listOf( BaseItemKind.AUDIO, BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM, ) - } - CollectionType.MUSICVIDEOS -> { - listOf(BaseItemKind.MUSIC_VIDEO) - } + CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET) + CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST) - CollectionType.BOXSETS -> { - listOf(BaseItemKind.BOX_SET) - } - - CollectionType.PLAYLISTS -> { - listOf(BaseItemKind.PLAYLIST) - } - - 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() - } + else -> listOf() } - -@Composable -@NonRestartableComposable -fun GridTitle( - title: String, - modifier: Modifier = Modifier, -) = Text( - text = title, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier.fillMaxWidth(), -) - -data class GridClickActions( - val onClickItem: (Int, BaseItem) -> Unit, - val onLongClickItem: ((Int, BaseItem) -> Unit)? = null, - val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null, - val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null, -) - -@Composable -fun CollectionFolderHeader( - listIsNotEmpty: Boolean, - showHeader: Boolean, - showTitle: Boolean, - playEnabled: Boolean, - title: String, - sortAndDirection: SortAndDirection, - onSortChange: (SortAndDirection) -> Unit, - sortOptions: List<ItemSortBy>, - 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, - enter = expandVertically(), - exit = shrinkVertically(), - modifier = modifier, - ) { - val focusRequester = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (showTitle) { - GridTitle(title) - } - val endPadding = - 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = endPadding) - .fillMaxWidth(), - ) { - 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), - ), - ) - } - - 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 deleted file mode 100644 index 57ff8957..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ /dev/null @@ -1,623 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -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.Row -import androidx.compose.foundation.layout.fillMaxHeight -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.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -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.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ListItemDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.AsyncImage -import coil3.compose.useExistingImageAsPlaceholder -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator -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.detail.GridItemDetails -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 -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -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.extensions.ticks - -@Composable -fun CollectionFolderViewScope.CollectionFolderList( - preferences: UserPreferences, - collectionType: CollectionType?, - focusedItem: BaseItem?, - items: List<BaseItem?>, - sortAndDirection: SortAndDirection, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - letterPosition: suspend (Char) -> Int, - onClickPlay: (Int, BaseItem) -> Unit, - initialPosition: Int, - gridFocusRequester: FocusRequester, - itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, - modifier: Modifier = Modifier, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - - var contentHashFocus by remember { mutableStateOf(false) } - var position by rememberInt(initialPosition) - val positionFocusRequester = remember { FocusRequester() } - val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME - - var showHeader by rememberSaveable { mutableStateOf(true) } - - fun jumpTo(newPosition: Int) { - scope.launch { - if (newPosition >= 0) { - position = newPosition - listState.animateScrollToItem(newPosition) - positionFocusRequester.tryRequestFocus() - } - } - } - - BackHandler(contentHashFocus && position > 0) { - jumpTo(0) - } - - Column(modifier = modifier) { - val topPadding by animateDpAsState( - targetValue = - when { - showHeader -> 8.dp - - // showClock-> TODO() - else -> 48.dp - }, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(top = topPadding, bottom = 16.dp) - .onFocusChanged { - contentHashFocus = it.hasFocus - }, - ) { - AnimatedVisibility( - visible = viewOptions.showDetails, - enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), - exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), - ) { - CollectionFolderListDetails( - item = focusedItem, - showLogo = true, - modifier = - Modifier - .fillMaxWidth(.33f) - .fillMaxHeight() - .background( - color = Color.Transparent, // MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ).padding(8.dp), - ) - } - this@CollectionFolderList.CollectionFolderListContent( - items = items, - listState = listState, - position = position, - positionFocusRequester = positionFocusRequester, - onPositionChange = { - showHeader = it < 1 - positionCallback?.invoke(1, it) - position = it - }, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - itemContent = itemContent, - modifier = - Modifier - .weight(1f) - .focusRequester(gridFocusRequester), - ) - val letters = stringResource(R.string.jump_letters) - // Letters - val currentLetter = - remember(focusedItem) { - focusedItem - ?.sortName - ?.firstOrNull() - ?.uppercaseChar() - ?.let { - when (it) { - in '0'..'9' -> '#' - in 'A'..'Z' -> it - else -> null - } - } - ?: letters[0] - } - if (showLetterButtons && items.isNotEmpty()) { - AlphabetButtons( - letters = letters, - currentLetter = currentLetter, - modifier = - Modifier - .align(Alignment.CenterVertically) - .padding(start = 16.dp), - letterClicked = { - scope.launchIO { - val position = letterPosition.invoke(it) - jumpTo(position) - } - }, - ) - } - } - } -} - -@OptIn(ExperimentalCoilApi::class) -@Composable -fun CollectionFolderListDetails( - item: BaseItem?, - showLogo: Boolean, - modifier: Modifier, -) { - val imageUrlService = LocalImageUrlService.current - val imageUrl = - remember(item) { - item?.imageUrlOverride ?: imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = true) - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, - ) { - SimpleTitleOrLogo( - item, - showLogo, - Modifier - .height(HeaderUtils.logoHeight) - .fillMaxWidth(), - ) - - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageUrl) - .crossfade(300) - .useExistingImageAsPlaceholder(true) - .build(), - contentDescription = null, - modifier = - Modifier - .clip(shape = RoundedCornerShape(8.dp)) - .weight(1f), - ) - - item?.let { - QuickDetails(item, null) - } - - OverviewText( - overview = item?.data?.overview ?: "", - maxLines = 5, - onClick = {}, - enabled = false, - modifier = Modifier, - ) - - // index? - } -} - -@Composable -fun CollectionFolderViewScope.CollectionFolderListContent( - items: List<BaseItem?>, - listState: LazyListState, - position: Int, - positionFocusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onPositionChange: (Int) -> Unit, - itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit, - modifier: Modifier = Modifier, -) { - val currentPosition by rememberUpdatedState(position) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), - modifier = modifier.focusRestorer(positionFocusRequester), - ) { - itemsIndexed(items) { index, item -> - val details = - remember(item, index, onClickItem, onLongClickItem, onPositionChange) { - GridItemDetails( - item = item, - index = index, - onClick = { item?.let { onClickItem.invoke(index, item) } }, - onLongClick = { item?.let { onLongClickItem.invoke(index, item) } }, - widthPx = -1, - mod = - Modifier - .onFocusChanged { - if (it.isFocused) onPositionChange.invoke(index) - }.ifElse( - index == currentPosition, - Modifier.focusRequester(positionFocusRequester), - ), - ) - } - itemContent.invoke(this@CollectionFolderListContent, details) - } - } -} - -@Composable -fun CollectionFolderListItem( - item: BaseItem?, - dense: Boolean, - collectionType: CollectionType?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, -) { - when (collectionType) { - CollectionType.MOVIES -> ListItemMovie(dense, item, onClick, onLongClick, modifier) - CollectionType.TVSHOWS -> ListItemSeries(dense, item, onClick, onLongClick, modifier) - CollectionType.MUSIC -> ListItemAlbum(dense, item, onClick, onLongClick, modifier) - else -> ListItemGeneric(dense, item, onClick, onLongClick, modifier) - } -} - -@Composable -fun ListItemGeneric( - dense: Boolean, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - ListItemWrapper( - dense = dense, - selected = false, - enabled = true, - onClick = onClick, - onLongClick = onLongClick, - leadingContent = { - if (item?.favorite == true) { - FavoriteIndicator() - } - }, - headlineContent = { TitleContent(item?.title, interactionSource) }, - supportingContent = - item?.subtitle?.let { subtitle -> - { - Text( - text = subtitle, - ) - } - }, - trailingContent = { - Text( - text = - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes - ?.let { LocalResources.current.formatDuration(it) } ?: "", - ) - }, - scale = scale, - colors = listItemColors(), - interactionSource = interactionSource, - modifier = modifier, - ) -} - -@Composable -fun ListItemMovie( - dense: Boolean, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val title = - remember(item, dense) { - if (dense && item != null && item.title != null && item.data.productionYear != null) { - "${item.title} (${ item.data.productionYear})" - } else { - item?.title ?: "" - } - } - val supportingContent = - remember(item, dense) { - if (!dense) { - @Composable { - Text(item?.subtitle ?: "") - } - } else { - null - } - } - ListItemWrapper( - dense = dense, - selected = false, - enabled = true, - onClick = onClick, - onLongClick = onLongClick, - leadingContent = { - if (item?.favorite == true) { - FavoriteIndicator() - } - }, - headlineContent = { TitleContent(title, interactionSource) }, - supportingContent = supportingContent, - trailingContent = { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes - ?.let { LocalResources.current.formatDuration(it) } ?: "", - ) - if (item?.played == true) { - WatchedIcon(padding = 0.dp) - } - } - }, - scale = scale, - colors = listItemColors(), - interactionSource = interactionSource, - modifier = modifier, - ) -} - -@Composable -fun ListItemSeries( - dense: Boolean, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val title = - remember(item, dense) { - if (item != null) { - if (dense) { - "${item.title} (${item.subtitle})" - } else { - item.title ?: "" - } - } else { - "" - } - } - val supportingContent = - remember(item, dense) { - if (!dense) { - @Composable { - Text(item?.subtitle ?: "") - } - } else { - null - } - } - val trailingContent = - remember(item) { - val unplayedItemCount = item?.data?.userData?.unplayedItemCount - if (unplayedItemCount != null && unplayedItemCount > 0) { - @Composable { Text(unplayedItemCount.toString()) } - } else if (item?.played == true) { - @Composable { WatchedIcon(padding = 0.dp) } - } else { - null - } - } - ListItemWrapper( - dense = dense, - selected = false, - enabled = true, - onClick = onClick, - onLongClick = onLongClick, - leadingContent = { - if (item?.favorite == true) { - FavoriteIndicator() - } - }, - headlineContent = { - TitleContent(title, interactionSource) - }, - supportingContent = supportingContent, - trailingContent = trailingContent, - scale = scale, - colors = listItemColors(), - interactionSource = interactionSource, - modifier = modifier, - ) -} - -@Composable -fun ListItemAlbum( - dense: Boolean, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val title = - remember(item, dense) { - if (dense && item != null && item.title != null && item.data.productionYear != null) { - "${item.title} (${item.data.productionYear})" - } else { - item?.title ?: "" - } - } - val supportingContent = - remember(item, dense) { - // Album artist is already shown as the overline - val subtitle = - if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle - if (!dense && subtitle != null) { - @Composable { - Text(subtitle) - } - } else { - null - } - } - val overlineContent = - remember(item, dense) { - if (!dense && item?.data?.albumArtist != null) { - @Composable { - Text(item.data.albumArtist ?: "") - } - } else { - null - } - } - val trailingContent = - remember(item) { - if (item?.type == BaseItemKind.MUSIC_ALBUM) { - @Composable { - Text( - text = - item - .data - .runTimeTicks - ?.ticks - ?.roundMinutes - ?.let { LocalResources.current.formatDuration(it) } ?: "", - ) - } - } else { - null - } - } - ListItemWrapper( - dense = dense, - selected = false, - enabled = true, - onClick = onClick, - onLongClick = onLongClick, - leadingContent = { - if (item?.favorite == true) { - FavoriteIndicator() - } - }, - headlineContent = { TitleContent(title, interactionSource) }, - supportingContent = supportingContent, - trailingContent = trailingContent, - overlineContent = overlineContent, - scale = scale, - colors = listItemColors(), - interactionSource = interactionSource, - modifier = modifier, - ) -} - -@Composable -private fun TitleContent( - title: String?, - interactionSource: MutableInteractionSource, - modifier: Modifier = Modifier, -) { - val focused by interactionSource.collectIsFocusedAsState() - 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 - } - Text( - text = title ?: "", - modifier = modifier.enableMarquee(focusedAfterDelay), - ) -} - -@Composable -fun listItemColors() = - ListItemDefaults.colors( - containerColor = - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .5f), - ) - -private val scale = ListItemDefaults.scale(focusedScale = 1f, pressedScale = .95f) 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 deleted file mode 100644 index 8445dfa9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ /dev/null @@ -1,1207 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.content.Context -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -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.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride -import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.FilterOptionCache -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.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.cards.GridCard -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.GridItemDetails -import com.github.damontecres.wholphin.ui.detail.music.addToQueue -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.playback.scale -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.goToRandom -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.GetAlbumArtistsHandler -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.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -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.CollectionType -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest -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 -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID - -@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) -class CollectionFolderViewModel - @AssistedInject - constructor( - private val savedStateHandle: SavedStateHandle, - private val api: ApiClient, - @param:ApplicationContext private val context: Context, - val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - private val navigationManager: NavigationManager, - private val themeSongPlayer: ThemeSongPlayer, - private val userPreferencesService: UserPreferencesService, - private val mediaManagementService: MediaManagementService, - private val musicService: MusicService, - val streamChoiceService: StreamChoiceService, - 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(), - ContextMenuProvider, - CollectionFolderViewActions { - @AssistedFactory - interface Factory { - fun create( - itemId: String, - initialSortAndDirection: SortAndDirection?, - @Assisted("recursive") recursive: Boolean, - collectionFilter: CollectionFolderFilter, - @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): CollectionFolderViewModel - } - - private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) - val state: StateFlow<CollectionFolderState> = _state - - private val artistSource: ArtistSource - get() = state.value.viewOptions.artistSource - - var position: Int - get() = savedStateHandle.get<Int>("position") ?: 0 - set(value) { - savedStateHandle["position"] = value - } - - init { - viewModelScope.launchIO { - try { - val item = - itemId.toUUIDOrNull()?.let { - api.userLibraryApi - .getItem(it) - .content - .let(::BaseItem) - } - - val libraryDisplayInfo = - serverRepository.currentUser?.let { user -> - val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId - libraryDisplayInfoDao.getItem(user, id) - } - val ancestorDisplayInfo = - if (libraryDisplayInfo?.viewOptions == null && item != null) { - getAncestorDisplayInfo(item) - } else { - null - } - _state.update { - it.copy( - viewOptions = - libraryDisplayInfo?.viewOptions - ?: ancestorDisplayInfo?.viewOptions - ?: defaultViewOptions, - ) - } - - val sortAndDirection = - if (collectionFilter.useSavedLibraryDisplayInfo) { - libraryDisplayInfo?.sortAndDirection - ?: ancestorDisplayInfo?.sortAndDirection - } else { - null - } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT - - val filterToUse = - if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { - collectionFilter.filter.merge(libraryDisplayInfo.filter) - } else { - collectionFilter.filter - } - - _state.update { it.copy(item = DataLoadingState.Success(item)) } - loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) - .join() -// onResumePage() - } catch (ex: Exception) { - Timber.e(ex, "Error during init") - _state.update { - it.copy( - item = DataLoadingState.Error(ex), - ) - } - } - } - mediaManagementService.deletedItemFlow - .onEach { deletedItem -> - refreshAfterDelete(position, deletedItem.item) - }.catch { ex -> - Timber.e(ex, "Error refreshing after deleted item") - }.launchIn(viewModelScope) - } - - private suspend fun refreshAfterDelete( - position: Int, - deletedItem: BaseItem, - ) { - try { - val pager = - ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) - position.let { - Timber.v("Item deleted: position=%s, id=%s", it, itemId) - val item = pager?.get(it) - // Exact item deleted (eg a movie) or deleted item was within the series - if (item?.id == deletedItem.id || - equalsNotNull(item?.data?.id, deletedItem.data.seriesId) - ) { - pager?.refreshPagesAfter(position) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error refreshing after deleted item %s", itemId) - showToast(context, "Error refreshing after item deleted") - } - } - - /** - * For a sub-folder, returns the saved display info of the closest ancestor that has one - */ - private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? { - if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) { - return null - } - val user = serverRepository.currentUser ?: return null - return try { - api.libraryApi - .getAncestors(item.id, user.id) - .content - .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) } - } catch (ex: Exception) { - Timber.w(ex, "Error getting ancestors for %s", item.id) - null - } - } - - private fun saveLibraryDisplayInfo( - newFilter: GetItemsFilter = state.value.filter, - newSort: SortAndDirection = state.value.sortAndDirection, - viewOptions: ViewOptions? = state.value.viewOptions, - ) { - if (collectionFilter.useSavedLibraryDisplayInfo) { - serverRepository.currentUser?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = collectionFilter.libraryDisplayInfoIdOverride ?: itemId, - sort = newSort.sort, - direction = newSort.direction, - filter = newFilter, - viewOptions = viewOptions, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - } - } - } - - override fun saveViewOptions(viewOptions: ViewOptions) { - position = 0 - val artistSourceChanged = viewOptions.artistSource != artistSource - _state.update { it.copy(viewOptions = viewOptions) } - if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) { - loadResults( - true, - state.value.sortAndDirection, - recursive, - state.value.filter, - useSeriesForPrimary, - ) - } - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - saveLibraryDisplayInfo(viewOptions = viewOptions) - if (!viewOptions.showBackdrop) { - backdropService.clearBackdrop() - } - } - } - - override fun onClickRandom() { - viewModelScope.launchIO { - try { - val state = state.value - val list = state.items.successValue as? BlockingList<BaseItem?> - if (list != null) { - goToRandom( - navigateTo = ::navigateTo, - parentId = itemId, - filter = state.filter, - sortAndDirection = state.sortAndDirection, - recursive = recursive, - list = list, - ) - } else { - Timber.w("state.value.items is not successful: %s", state.items) - } - } 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, - ) { - Timber.v("onFilterChange: filter=%s", newFilter) - saveLibraryDisplayInfo(newFilter) - loadResults( - false, - state.value.sortAndDirection, - recursive, - newFilter, - useSeriesForPrimary, - ) - } - - override fun onSortChange( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ) { - Timber.v( - "onSortChange: sort=%s, recursive=%s, filter=%s", - sortAndDirection, - recursive, - filter, - ) - saveLibraryDisplayInfo(filter, sortAndDirection) - loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) - } - - private fun loadResults( - resetState: Boolean, - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ) = viewModelScope.launch(WholphinDispatchers.IO) { - _state.update { - it.copy( - items = DataLoadingState.Loading, - backgroundLoading = LoadingState.Loading, - sortAndDirection = sortAndDirection, - filter = filter, - ) - } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - _state.update { - it.copy( - items = DataLoadingState.Success(newPager), - backgroundLoading = LoadingState.Success, - ) - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - _state.update { - it.copy( - items = DataLoadingState.Error(ex), - backgroundLoading = LoadingState.Pending, - ) - } - } - } - - private fun createPager( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ): ApiRequestPager<out Any> = - when (filter.override) { - GetItemsFilterOverride.NONE -> { - val request = - createGetItemsRequest( - sortAndDirection = sortAndDirection, - recursive = recursive, - filter = filter, - ) - val newPager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - - GetItemsFilterOverride.PERSON -> { - val request = - filter.applyTo( - GetPersonsRequest( - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - ), - ) - val newPager = - ApiRequestPager( - api, - request, - GetPersonsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - - GetItemsFilterOverride.ARTIST -> { - when (artistSource) { - ArtistSource.ARTIST -> { - ApiRequestPager( - api, - createGetArtistsRequest(filter), - GetArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - } - - ArtistSource.ALBUM_ARTIST -> { - ApiRequestPager( - api, - createGetAlbumArtistsRequest(filter), - GetAlbumArtistsHandler, - 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 createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest { - val item = state.value.item.successValue - return filter.applyTo( - GetAlbumArtistsRequest( - parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - fields = SlimItemFields, - ), - ) - } - - private fun createGetItemsRequest( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ): GetItemsRequest { - val item = state.value.item.successValue - val includeItemTypes = - item - ?.data - ?.collectionType - ?.baseItemKinds - .orEmpty() - val request = - filter.applyTo( - GetItemsRequest( - parentId = item?.id, - enableImageTypes = - listOf( - ImageType.PRIMARY, - ImageType.THUMB, - ImageType.BACKDROP, - ImageType.LOGO, - ), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = item?.let { listOf(item.id) }, - sortBy = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.sort) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(ItemSortBy.PRODUCTION_YEAR) - } - } - }, - sortOrder = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.direction) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(SortOrder.ASCENDING) - } - } - }, - fields = SlimItemFields, - ), - ) - return request - } - - override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - filterOptionCache.getFilterOptionValues( - itemId.toUUID(), - filterOption, - ) - - /** - * 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 - when (filter.override) { - GetItemsFilterOverride.ARTIST -> { - when (artistSource) { - ArtistSource.ARTIST -> { - GetArtistsHandler.countMatching( - api = api, - request = - createGetArtistsRequest(filter).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - enableUserData = false, - ), - ) - } - - ArtistSource.ALBUM_ARTIST -> { - GetAlbumArtistsHandler.countMatching( - api = api, - request = - createGetAlbumArtistsRequest(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, - ), - ) - } - } - } - - 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 updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - override fun navigateTo(destination: Destination) { - release() - navigationManager.navigateTo(destination) - } - - fun release() { - themeSongPlayer.stop() - } - - fun onResumePage() { - viewModelScope.launchIO { - state.value.item.successValue?.let { - Timber.v("onResumePage: %s", state.value.items::class) - if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) { - themeSongPlayer.playThemeFor(it.id) - } - } - } - } - - override fun deleteItem( - index: Int, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - refreshAfterDelete(index, item) - } - } - } - - override fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun addToQueue( - 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( - val item: DataLoadingState<BaseItem?> = DataLoadingState.Loading, - val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Loading, - val backgroundLoading: LoadingState = LoadingState.Loading, - val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT, - val filter: GetItemsFilter = GetItemsFilter(), - val viewOptions: ViewOptions, -) - -/** - * Shows a collection folder as a grid - * - * This is the "Library" tab for Movies or TV shows - */ -@Composable -fun CollectionFolderView( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderView( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - GridClickActions(onClickItem = onClickItem), - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderView( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderView( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - actions, - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderView( - preferences: UserPreferences, - itemId: String, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List<ItemSortBy>, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId, - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, - viewModel: CollectionFolderViewModel = - hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( - key = viewModelKey, - ) { - it.create( - itemId = itemId, - initialSortAndDirection = initialSortAndDirection, - recursive = recursive, - collectionFilter = initialFilter, - useSeriesForPrimary = useSeriesForPrimary, - defaultViewOptions = defaultViewOptions, - ) - }, -) { - val state by viewModel.state.collectAsState() - LifecycleResumeEffect(itemId) { - viewModel.onResumePage() - - 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, - gridCardContent: @Composable CollectionFolderViewScope.( - GridItemDetails<BaseItem>, - ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) - }, - listItemContent: @Composable CollectionFolderViewScope.( - GridItemDetails<BaseItem>, - ) -> Unit = { (item, index, onClick, onLongClick, _, mod) -> - CollectionFolderListItem( - item = item, - dense = viewOptions.type == ViewOptionsType.DENSE_LIST, - collectionType = collectionType, - onClick = onClick, - onLongClick = onLongClick, - modifier = mod, - ) - }, -) { - 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) { - GridClickActions( - onClickItem = { index, item -> - position = index - actions.onClickItem.invoke(index, item) - }, - onLongClickItem = { index, item -> - position = index - if (actions.onLongClickItem != null) { - actions.onLongClickItem.invoke(index, item) - } else { - contextMenu.showContextMenu(index, item) - } - }, - onClickPlayAll = - actions.onClickPlayAll ?: { shuffle -> - itemId.toUUIDOrNull()?.let { - val destination = - if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = it, - index = 0, - filter = CollectionFolderFilter(filter = state.filter), - sortAndDirection = state.sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = state.sortAndDirection, - filter = state.filter, - ) - } - viewActions.navigateTo(destination) - } - Unit - }, - onClickPlayRemoteButton = - actions.onClickPlayRemoteButton ?: { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = item.id, - index = index, - filter = CollectionFolderFilter(filter = state.filter), - sortAndDirection = state.sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.Playback(item) - } - viewActions.navigateTo(destination) - }, - ) - } - Box(modifier = modifier) { - when (val st = state.item) { - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(Modifier.fillMaxSize()) - } - - is DataLoadingState.Error -> { - ErrorMessage(st, Modifier.fillMaxSize()) - } - - is DataLoadingState.Success<BaseItem?> -> { - val item = st.successValue - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Column(modifier = Modifier.fillMaxSize()) { - var showHeader by rememberSaveable { mutableStateOf(true) } - val gridFocusRequester = remember { FocusRequester() } - val pager = remember(state.items) { state.items.successValue } - - val focusedItem = pager?.getOrNull(position) - if (state.viewOptions.showBackdrop) { - LaunchedEffect(focusedItem) { - focusedItem?.let(viewActions::updateBackdrop) - } - } - CollectionFolderHeader( - listIsNotEmpty = state.items.successValue?.isNotEmpty() == true, - showHeader = showHeader || state.items !is DataLoadingState.Success, - showTitle = showTitle, - playEnabled = playEnabled, - title = title, - sortAndDirection = state.sortAndDirection, - onSortChange = { - viewActions.onSortChange(it, recursive, state.filter) - }, - sortOptions = sortOptions, - currentFilter = state.filter, - onFilterChange = { - viewActions.onFilterChange(it, recursive) - }, - getPossibleFilterValues = { - 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) { - is DataLoadingState.Error -> { - LaunchedEffect(Unit) { - (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() - } - ErrorMessage(pager, Modifier.fillMaxSize()) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(Modifier.fillMaxSize()) - } - - is DataLoadingState.Success<List<BaseItem?>> -> { - LaunchedEffect(Unit) { - val focusRequester = - when { - contextMenu.isShowing -> null - filterDropdownShowing -> filterButtonFocusRequester - clickedRandom -> randomButtonFocusRequester - pager.data.isNotEmpty() -> gridFocusRequester - else -> focusRequesterOnEmpty ?: headerRowFocusRequester - } - focusRequester?.tryRequestFocus() - clickedRandom = false - } - val scope = - object : CollectionFolderViewScope { - override val viewOptions: ViewOptions - get() = state.viewOptions - override val collectionType: CollectionType? - get() = item?.data?.collectionType - override val sortAndDirection: SortAndDirection - get() = state.sortAndDirection - override val collectionSize: Int - get() = pager.data.size - } - - Box(Modifier.fillMaxSize()) { - if (state.viewOptions.type == ViewOptionsType.GRID) { - scope.CollectionFolderGrid( - preferences = preferences, - initialPosition = savedPosition, - items = pager.data, - sortAndDirection = state.sortAndDirection, - modifier = Modifier.fillMaxSize(), - gridFocusRequester = gridFocusRequester, - onClickItem = gridActions.onClickItem, - onLongClickItem = gridActions.onLongClickItem!!, - positionCallback = { columns, pos -> - showHeader = pos < columns - position = pos - positionCallback?.invoke(columns, pos) - }, - letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - onClickPlay = gridActions.onClickPlayRemoteButton!!, - focusedItem = focusedItem, - cardContent = gridCardContent, - ) - } else { - scope.CollectionFolderList( - preferences = preferences, - collectionType = item?.data?.collectionType, - initialPosition = savedPosition, - items = pager.data, - sortAndDirection = state.sortAndDirection, - modifier = Modifier.fillMaxSize(), - gridFocusRequester = gridFocusRequester, - onClickItem = gridActions.onClickItem, - onLongClickItem = gridActions.onLongClickItem!!, - positionCallback = { columns, pos -> - showHeader = pos < columns - position = pos - positionCallback?.invoke(columns, pos) - }, - letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - onClickPlay = gridActions.onClickPlayRemoteButton!!, - focusedItem = focusedItem, - itemContent = listItemContent, - ) - } - androidx.compose.animation.AnimatedVisibility( - state.backgroundLoading == LoadingState.Loading, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - CircularProgress( - Modifier - .background( - MaterialTheme.colorScheme.background.copy(alpha = .25f), - shape = CircleShape, - ).size(64.dp) - .padding(4.dp), - ) - } - } - } - } - } - } - } - } - contextMenu.Compose() - AnimatedVisibility(showViewOptions) { - ViewOptionsDialog( - viewOptions = state.viewOptions, - defaultViewOptions = defaultViewOptions, - showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST, - onDismissRequest = { - showViewOptions = false - viewActions.saveViewOptions(state.viewOptions) - }, - onViewOptionsChange = viewActions::saveViewOptions, - ) - } -} - -/** - * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items - */ -interface CollectionFolderViewScope { - val viewOptions: ViewOptions - val collectionType: CollectionType? - val sortAndDirection: SortAndDirection - val collectionSize: Int -} 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 deleted file mode 100644 index f87010d9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ /dev/null @@ -1,970 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -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.Info -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Refresh -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.graphics.Color -import androidx.compose.ui.platform.LocalResources -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 -import com.github.damontecres.wholphin.data.model.AudioItem -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.Person -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 -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import kotlin.time.Duration - -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, - val canDelete: Boolean, - val canRemoveContinueWatching: Boolean, - val canRemoveNextUp: Boolean, - val actions: ContextMenuActions, - val showRemoveFromPlaylist: Boolean = false, - ) : ContextMenu - - data class ForPerson( - val fromLongClick: Boolean, - val person: Person, - val actions: PersonContextActions, - ) : ContextMenu - - data class ForMusic( - val fromLongClick: Boolean, - val item: BaseItem, - val index: Int, - val canDelete: Boolean, - val canRemoveFromQueue: Boolean, - val actions: MusicContextActions, - val showRemoveFromPlaylist: Boolean = false, - val showGoTo: Boolean = false, - ) : ContextMenu - - data class ForQueue( - val fromLongClick: Boolean, - val item: AudioItem, - val index: Int, - val actions: QueueContextActions, - ) : ContextMenu - - companion object { - const val UNSPECIFIED_INDEX = -12 - } -} - -data class ContextMenuActions( - val navigateTo: (Destination) -> Unit, - val onShowOverview: (BaseItem) -> Unit, - val onClickWatch: (UUID, Boolean) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, - val onClickAddPlaylist: (UUID) -> Unit, - val onSendMediaInfo: (UUID) -> Unit, - val onDeleteItem: (BaseItem) -> Unit, - val onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, - val onChooseTracks: (ChosenTrackResult) -> Unit, - val onClearChosenStreams: (ChosenStreams?) -> Unit, - val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, - val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, - val onClickAddToQueue: (BaseItem) -> Unit = {}, - val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, -) - -data class PersonContextActions( - val navigateTo: (Destination) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, -) - -data class MusicContextActions( - val navigateTo: (Destination) -> Unit, - val onClickPlay: (Int, BaseItem) -> Unit, - val onClickPlayNext: (Int, BaseItem) -> Unit, - val onClickFavorite: (UUID, Boolean) -> Unit, - val onClickAddPlaylist: (UUID) -> Unit, - val onDeleteItem: (BaseItem) -> Unit, - val onClickAddToQueue: (BaseItem) -> Unit, - val onClickRemoveFromQueue: (Int, BaseItem) -> Unit, - val onClickGoToAlbum: (UUID) -> Unit = { - navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) - }, - val onClickGoToArtist: (UUID) -> Unit = { - navigateTo.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) - }, - val onRemoveFromPlaylist: (Int, UUID) -> Unit = { _, _ -> }, -) - -data class QueueContextActions( - val onNavigate: (Destination) -> Unit, - val onClickPlay: (Int, AudioItem) -> Unit, - val onClickPlayNext: (Int, AudioItem) -> Unit, - val onClickGoToAlbum: (java.util.UUID) -> Unit = { - onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ALBUM)) - }, - val onClickGoToArtist: (java.util.UUID) -> Unit = { - onNavigate.invoke(Destination.MediaItem(itemId = it, type = BaseItemKind.MUSIC_ARTIST)) - }, - val onClickRemoveFromQueue: (Int, AudioItem) -> Unit, -) - -data class ChosenTrackResult( - val item: BaseItem, - val streamType: MediaStreamType, - val trackIndex: Int, - val itemPlayback: ItemPlayback?, -) - -@Composable -fun ContextMenuDialog( - onDismissRequest: () -> Unit, - contextMenu: ContextMenu, - getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?, - preferredSubtitleLanguage: String?, -) { - when (contextMenu) { - is ContextMenu.ForBaseItem -> { - ContextMenu( - onDismissRequest, - contextMenu, - getMediaSource, - preferredSubtitleLanguage, - contextMenu.actions, - ) - } - - is ContextMenu.ForPerson -> { - ContextMenu( - onDismissRequest = onDismissRequest, - item = contextMenu, - actions = contextMenu.actions, - ) - } - - is ContextMenu.ForMusic -> { - ContextMenu( - onDismissRequest = onDismissRequest, - item = contextMenu, - actions = contextMenu.actions, - ) - } - - is ContextMenu.ForQueue -> { - ContextMenu( - onDismissRequest = onDismissRequest, - item = contextMenu, - actions = contextMenu.actions, - ) - } - } -} - -@Composable -fun ContextMenu( - onDismissRequest: () -> Unit, - contextMenu: ContextMenu.ForBaseItem, - getMediaSource: ((dto: BaseItemDto, itemPlayback: ItemPlayback?) -> MediaSourceInfo?)?, - preferredSubtitleLanguage: String?, - actions: ContextMenuActions, -) { - val item = contextMenu.item - val chosenStreams = contextMenu.chosenStreams - val resources = LocalResources.current - var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } - var showDeleteDialog by remember { mutableStateOf(false) } - var showPlayWithDialog by remember { mutableStateOf(false) } - - val dialogItems = - remember(resources, item, chosenStreams) { - buildContextMenuItems( - resources = resources, - item = item, - watched = item.played, - favorite = item.favorite, - seriesId = item.data.seriesId, - sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = - chosenStreams.let { - it?.itemPlayback != null || - it?.audioStc?.isNotEmpty() == true || - it?.subtitleStc?.isNotEmpty() == true - }, - contextMenu = contextMenu, - actions = actions, - onChooseVersion = { - chooseVersion = - chooseVersionParams( - resources, - item.data.mediaSources.orEmpty(), - chosenStreams?.source?.id?.toUUIDOrNull(), - ) { idx -> - val source = item.data.mediaSources!![idx] - actions.onChooseVersion.invoke(item, source) - onDismissRequest.invoke() - } - }, - onChooseTracks = { type -> - getMediaSource - ?.invoke( - item.data, - chosenStreams?.itemPlayback, - )?.let { source -> - chooseVersion = - chooseStream( - resources = resources, - streams = source.mediaStreams.orEmpty(), - type = type, - streamChoice = - if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream - } else { - chosenStreams?.subtitleStream - }, - onClick = { trackIndex -> - actions.onChooseTracks.invoke( - ChosenTrackResult( - item = item, - streamType = type, - trackIndex = trackIndex, - itemPlayback = chosenStreams?.itemPlayback, - ), - ) - onDismissRequest.invoke() - }, - preferredSubtitleLanguage = preferredSubtitleLanguage, - ) - } - }, - onShowOverview = { actions.onShowOverview.invoke(item) }, - onClearChosenStreams = { - actions.onClearChosenStreams.invoke(chosenStreams) - }, - onClickDelete = { showDeleteDialog = true }, - onClickPlayWith = { showPlayWithDialog = true }, - ) - } - DialogPopup( - showDialog = true, - title = item.title ?: "", - dialogItems = dialogItems, - onDismissRequest = onDismissRequest, - dismissOnClick = false, - waitToLoad = contextMenu.fromLongClick, - ) - if (chooseVersion != null) { - chooseVersion?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { chooseVersion = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - elevation = 3.dp, - ) - } - } - if (showDeleteDialog) { - ConfirmDeleteDialog( - itemTitle = item.title ?: "", - onCancel = { showDeleteDialog = false }, - onConfirm = { - actions.onDeleteItem.invoke(item) - onDismissRequest.invoke() - }, - elevation = 3.dp, - ) - } - if (showPlayWithDialog) { - val dialogItems = - remember { - buildPlayWith(resources) { transcode, backend -> - onDismissRequest.invoke() - actions.navigateTo( - Destination.Playback( - itemId = item.id, - positionMs = item.resumeMs, - forceTranscoding = transcode, - backend = backend, - ), - ) - } - } - DialogPopup( - showDialog = true, - title = stringResource(R.string.play_with), - dialogItems = dialogItems, - onDismissRequest = { showPlayWithDialog = false }, - dismissOnClick = true, - waitToLoad = false, - elevation = 3.dp, - ) - } -} - -private fun buildContextMenuItems( - resources: Resources, - contextMenu: ContextMenu.ForBaseItem, - item: BaseItem, - seriesId: UUID?, - sourceId: UUID?, - watched: Boolean, - favorite: Boolean, - canClearChosenStreams: Boolean, - actions: ContextMenuActions, - onChooseVersion: () -> Unit, - onChooseTracks: (MediaStreamType) -> Unit, - onShowOverview: () -> Unit, - onClearChosenStreams: () -> Unit, - onClickDelete: () -> Unit, - onClickPlayWith: () -> Unit, -): List<DialogItem> = - buildList { - // Songs should not show Go to - if (contextMenu.showGoTo && item.type != BaseItemKind.AUDIO) { - add( - DialogItem( - resources.getString(R.string.go_to), - Icons.Default.ArrowForward, - dismissOnClick = true, - ) { - actions.onClickGoTo(item) - }, - ) - } - if (item.type in supportedPlayableTypes) { - if (item.playbackPosition > Duration.ZERO) { - add( - DialogItem( - resources.getString(R.string.resume), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.Playback( - item.id, - item.playbackPosition.inWholeMilliseconds, - ), - ) - }, - ) - add( - DialogItem( - resources.getString(R.string.restart), - Icons.Default.Refresh, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.Playback( - item.id, - 0L, - ), - ) - }, - ) - } else { - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.Playback( - item.id, - 0L, - ), - ) - }, - ) - } - } - 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 } } - ?: sources.firstOrNull() - source?.mediaStreams?.letNotEmpty { streams -> - val audioCount = streams.count { it.type == MediaStreamType.AUDIO } - val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE } - if (audioCount > 1) { - add( - DialogItem( - resources.getString( - R.string.choose_stream, - resources.getString(R.string.audio), - ), - R.string.fa_volume_low, - dismissOnClick = false, - ) { - onChooseTracks.invoke(MediaStreamType.AUDIO) - }, - ) - } - if (subtitleCount > 0) { - add( - DialogItem( - resources.getString( - R.string.choose_stream, - resources.getString(R.string.subtitles), - ), - R.string.fa_closed_captioning, - dismissOnClick = false, - ) { - onChooseTracks.invoke(MediaStreamType.SUBTITLE) - }, - ) - } - } - if (sources.size > 1) { - add( - DialogItem( - resources.getString( - R.string.choose_stream, - resources.getString(R.string.version), - ), - R.string.fa_file_video, - dismissOnClick = false, - ) { - onChooseVersion.invoke() - }, - ) - } - } - } - if (item.type == BaseItemKind.MUSIC_ALBUM || item.type == BaseItemKind.AUDIO) { - add( - DialogItem( - resources.getString(R.string.add_to_queue), - Icons.Default.Add, - dismissOnClick = true, - ) { - actions.onClickAddToQueue(item) - }, - ) - } - 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, - iconStringRes = R.string.fa_list_ul, - dismissOnClick = true, - ) { - actions.onClickAddPlaylist.invoke(item.id) - }, - ) - if (contextMenu.canDelete) { - add( - DialogItem( - resources.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - dismissOnClick = false, - ) { - onClickDelete.invoke() - }, - ) - } - if (contextMenu.canRemoveContinueWatching && !watched && item.playbackPosition > Duration.ZERO) { - add( - DialogItem( - text = R.string.remove_continue_watching, - iconStringRes = R.string.fa_eye, - dismissOnClick = true, - ) { - actions.onClickWatch.invoke(item.id, false) - }, - ) - } - if (contextMenu.canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { - add( - DialogItem( - text = R.string.remove_next_up, - iconStringRes = R.string.fa_tag, - dismissOnClick = true, - ) { - 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, - dismissOnClick = true, - ) { - actions.onClickWatch.invoke(item.id, !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, - dismissOnClick = true, - ) { - actions.onClickFavorite.invoke(item.id, !favorite) - }, - ) - item.data.albumId?.let { albumId -> - add( - DialogItem( - resources.getString(R.string.go_to_album), - R.string.fa_compact_disc, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.MediaItem( - albumId, - BaseItemKind.MUSIC_ALBUM, - null, - ), - ) - }, - ) - } - item.data.artistItems?.firstOrNull()?.id?.let { artistId -> - add( - DialogItem( - resources.getString(R.string.go_to_artist), - R.string.fa_user, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.MediaItem( - artistId, - BaseItemKind.MUSIC_ARTIST, - null, - ), - ) - }, - ) - } - seriesId?.let { - add( - DialogItem( - resources.getString(R.string.go_to_series), - Icons.AutoMirrored.Filled.ArrowForward, - dismissOnClick = true, - ) { - actions.navigateTo( - Destination.MediaItem( - seriesId, - BaseItemKind.SERIES, - null, - ), - ) - }, - ) - } - if (item.data.mediaSources?.isNotEmpty() == true) { - add( - DialogItem( - resources.getString(R.string.media_information), - Icons.Default.Info, - dismissOnClick = true, - ) { - onShowOverview.invoke() - }, - ) - } - if (contextMenu.showStreamChoices && canClearChosenStreams) { - add( - DialogItem( - resources.getString(R.string.clear_track_choices), - Icons.Default.Delete, - dismissOnClick = true, - ) { - onClearChosenStreams() - }, - ) - } - if (item.type in supportedPlayableTypes) { - add( - DialogItem( - resources.getString(R.string.play_with), - Icons.Default.PlayArrow, - dismissOnClick = false, - onClick = onClickPlayWith, - ), - ) - } - if (item.data.mediaSources?.isNotEmpty() == true) { - add( - DialogItem( - text = R.string.send_media_info_log_to_server, - iconStringRes = R.string.fa_file_video, - dismissOnClick = true, - ) { - actions.onSendMediaInfo.invoke(item.id) - }, - ) - } - } - -private fun buildPlayWith( - resources: Resources, - onClick: (Boolean, PlayerBackend?) -> Unit, -) = buildList { - val entries = - PlayerBackend.entries - .filterNot { it == PlayerBackend.UNRECOGNIZED } - .zip(resources.getStringArray(R.array.player_backend_options)) - .filterNot { it.first == PlayerBackend.PREFER_MPV } - entries.forEach { (backend, title) -> - add( - DialogItem( - title, - dismissOnClick = true, - ) { - onClick.invoke(false, backend) - }, - ) - } - add( - DialogItem( - resources.getString(R.string.transcoding), - dismissOnClick = true, - ) { - onClick.invoke(true, null) - }, - ) -} - -@Composable -fun ContextMenu( - onDismissRequest: () -> Unit, - item: ContextMenu.ForPerson, - actions: PersonContextActions, -) { - val person = item.person - val dialogItems = - buildList { - val itemId = person.id - add( - DialogItem( - stringResource(R.string.go_to), - Icons.Default.ArrowForward, - ) { - actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) - }, - ) - add( - DialogItem( - text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (person.favorite) Color.Red else Color.Unspecified, - ) { - actions.onClickFavorite.invoke(itemId, !person.favorite) - }, - ) - } - DialogPopup( - showDialog = true, - title = person.name ?: "", - dialogItems = dialogItems, - onDismissRequest = onDismissRequest, - dismissOnClick = true, - waitToLoad = item.fromLongClick, - ) -} - -@Composable -fun ContextMenu( - onDismissRequest: () -> Unit, - item: ContextMenu.ForMusic, - actions: MusicContextActions, -) { - val resources = LocalResources.current - var showDeleteDialog by remember { mutableStateOf(false) } - val dialogItems = - remember(resources, item, actions) { - buildContextForMusic(resources, item, actions, onClickDelete = { - showDeleteDialog = true - }) - } - DialogPopup( - showDialog = true, - title = item.item.title ?: "", - dialogItems = dialogItems, - onDismissRequest = onDismissRequest, - dismissOnClick = false, - waitToLoad = item.fromLongClick, - ) - if (showDeleteDialog) { - ConfirmDeleteDialog( - itemTitle = item.item.title ?: "", - onCancel = { showDeleteDialog = false }, - onConfirm = { - actions.onDeleteItem.invoke(item.item) - onDismissRequest.invoke() - }, - ) - } -} - -fun buildContextForMusic( - resources: Resources, - music: ContextMenu.ForMusic, - actions: MusicContextActions, - onClickDelete: () -> Unit, -): List<DialogItem> = - buildList { - val item = music.item - val index = music.index - // Songs should not show Go to - if (music.showGoTo && item.type != BaseItemKind.AUDIO) { - add( - DialogItem( - resources.getString(R.string.go_to), - Icons.Default.ArrowForward, - dismissOnClick = true, - ) { - actions.navigateTo(item.destination()) - }, - ) - } - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.onClickPlay(index, item) - }, - ) - add( - DialogItem( - resources.getString(R.string.play_next), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.onClickPlayNext(index, item) - }, - ) - if (music.canRemoveFromQueue) { - add( - DialogItem( - resources.getString(R.string.remove_from_queue), - Icons.Default.Delete, - dismissOnClick = true, - ) { - actions.onClickRemoveFromQueue(index, item) - }, - ) - } else { - add( - DialogItem( - resources.getString(R.string.add_to_queue), - Icons.Default.Add, - dismissOnClick = true, - ) { - actions.onClickAddToQueue(item) - }, - ) - } - 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, - iconStringRes = R.string.fa_list_ul, - dismissOnClick = true, - ) { - actions.onClickAddPlaylist.invoke(item.id) - }, - ) - if (music.canDelete) { - add( - DialogItem( - resources.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - dismissOnClick = false, - ) { - onClickDelete.invoke() - }, - ) - } - add( - DialogItem( - text = if (item.favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (item.favorite) Color.Red else Color.Unspecified, - dismissOnClick = true, - ) { - actions.onClickFavorite.invoke(item.id, !item.favorite) - }, - ) - if (item.type == BaseItemKind.AUDIO && item.data.albumId != null) { - add( - DialogItem( - resources.getString(R.string.go_to_album), - R.string.fa_compact_disc, - dismissOnClick = true, - ) { - actions.onClickGoToAlbum.invoke(item.data.albumId!!) - }, - ) - } - if ((item.type == BaseItemKind.AUDIO || item.type == BaseItemKind.MUSIC_ALBUM) && item.data.artistItems?.isNotEmpty() == true) { - add( - DialogItem( - resources.getString(R.string.go_to_artist), - R.string.fa_user, - dismissOnClick = true, - ) { - actions.onClickGoToArtist.invoke( - item.data.artistItems!! - .first() - .id, - ) - }, - ) - } - } - -@Composable -fun ContextMenu( - onDismissRequest: () -> Unit, - item: ContextMenu.ForQueue, - actions: QueueContextActions, -) { - val resources = LocalResources.current - val dialogItems = - remember(resources, item, actions) { - buildContextForMusicQueue( - resources, - item.item, - item.index, - actions, - ) - } - DialogPopup( - showDialog = true, - title = item.item.title ?: "", - dialogItems = dialogItems, - onDismissRequest = onDismissRequest, - dismissOnClick = false, - waitToLoad = item.fromLongClick, - ) -} - -fun buildContextForMusicQueue( - resources: Resources, - item: AudioItem, - index: Int, - actions: QueueContextActions, -): List<DialogItem> = - buildList { - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.onClickPlay(index, item) - }, - ) - add( - DialogItem( - resources.getString(R.string.play_next), - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - actions.onClickPlayNext(index, item) - }, - ) - add( - DialogItem( - resources.getString(R.string.remove_from_queue), - Icons.Default.Delete, - dismissOnClick = true, - ) { - actions.onClickRemoveFromQueue(index, item) - }, - ) - if (item.albumId != null) { - add( - DialogItem( - resources.getString(R.string.go_to_album), - Icons.Default.ArrowForward, - dismissOnClick = true, - ) { - actions.onClickGoToAlbum.invoke(item.albumId) - }, - ) - } - if (item.artistId != null) { - add( - DialogItem( - resources.getString(R.string.go_to_artist), - Icons.Default.ArrowForward, - dismissOnClick = true, - ) { - actions.onClickGoToArtist.invoke(item.artistId) - }, - ) - } - } 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 deleted file mode 100644 index 2261e0ac..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenuUtils.kt +++ /dev/null @@ -1,166 +0,0 @@ -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/DetailsBackdropImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt new file mode 100644 index 00000000..c02f7f22 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.tv.material3.MaterialTheme +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.ui.isNotNullOrBlank + +@Composable +fun BoxScope.DetailsBackdropImage( + backdropImageUrl: String?, + modifier: Modifier = Modifier, +) { + if (backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + modifier + .align(Alignment.TopEnd) + .fillMaxHeight(.85f) + .alpha(.75f) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = size.height * .5f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(Color.Transparent, gradientColor), + endX = 0f, + startX = size.width * .75f, + ), + ) + }, + ) + } +} 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 76b7dc77..b2363059 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 @@ -1,17 +1,14 @@ package com.github.damontecres.wholphin.ui.components -import android.content.res.Resources +import android.content.Context import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -19,14 +16,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,12 +29,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -55,6 +45,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.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor @@ -63,22 +54,14 @@ 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.services.StreamChoiceResult import com.github.damontecres.wholphin.ui.FontAwesome -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.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 /** * Parameters for rendering a [DialogPopup] @@ -86,16 +69,13 @@ import java.util.UUID data class DialogParams( val fromLongClick: Boolean, val title: String, - val items: List<DialogItemEntry>, + val items: List<DialogItem>, ) sealed interface DialogItemEntry data object DialogItemDivider : DialogItemEntry -/** - * An item within a [DialogPopup] - */ data class DialogItem( val headlineContent: @Composable () -> Unit, val onClick: () -> Unit, @@ -104,14 +84,11 @@ data class DialogItem( val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, - val selected: Boolean = false, - val dismissOnClick: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @StringRes iconStringRes: Int, iconColor: Color = Color.Unspecified, - dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -127,13 +104,11 @@ data class DialogItem( ) }, onClick = onClick, - dismissOnClick = dismissOnClick, ) constructor( text: String, @StringRes iconStringRes: Int, - dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -150,14 +125,12 @@ data class DialogItem( ) }, onClick = onClick, - dismissOnClick = dismissOnClick, ) constructor( text: String, icon: ImageVector, iconColor: Color? = null, - dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -174,12 +147,10 @@ data class DialogItem( ) }, onClick = onClick, - dismissOnClick = dismissOnClick, ) constructor( text: String, - dismissOnClick: Boolean = false, onClick: () -> Unit, ) : this( headlineContent = { @@ -189,7 +160,6 @@ data class DialogItem( ) }, onClick = onClick, - dismissOnClick = dismissOnClick, ) companion object { @@ -211,7 +181,7 @@ fun DialogPopup( dismissOnClick: Boolean = true, waitToLoad: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 1.dp, + elevation: Dp = 3.dp, ) { var waiting by remember { mutableStateOf(waitToLoad) } if (showDialog) { @@ -231,35 +201,28 @@ fun DialogPopup( onDismissRequest = onDismissRequest, properties = properties, ) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) DialogPopupContent( title = title, dialogItems = dialogItems, waiting = waiting, onDismissRequest = onDismissRequest, dismissOnClick = dismissOnClick, + elevation = elevation, modifier = - Modifier - .onKeyEvent { event -> - val code = event.nativeKeyEvent.keyCode - if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && - code in - setOf( - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, - ) - ) { - waiting = false - } - false - }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + Modifier.onKeyEvent { event -> + val code = event.nativeKeyEvent.keyCode + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && + code in + setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + ) + ) { + waiting = false + } + false + }, ) } } @@ -273,61 +236,49 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, + elevation: Dp = 3.dp, ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + val elevatedContainerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) + LazyColumn( + modifier = + modifier +// .widthIn(min = 520.dp, max = 300.dp) +// .dialogFocusable() + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } - LazyColumn( - state = listState, - modifier = Modifier, - ) { - itemsIndexed(dialogItems) { index, item -> - when (item) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } + stickyHeader { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + items(dialogItems) { + when (it) { + is DialogItemDivider -> HorizontalDivider(Modifier.height(16.dp)) - is DialogItem -> { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - ListItem( - selected = item.selected, - enabled = !waiting && item.enabled, - onClick = { - if (dismissOnClick || item.dismissOnClick) { - onDismissRequest.invoke() - } - item.onClick.invoke() - }, - headlineContent = item.headlineContent, - overlineContent = item.overlineContent, - supportingContent = item.supportingContent, - leadingContent = item.leadingContent, - trailingContent = item.trailingContent, - interactionSource = interactionSource, - modifier = - Modifier - .focusRequester(focusRequesters[index]) - .lazyListWrapScrolling( - listState, - focused, - index == 0, - index == dialogItems.lastIndex, - focusRequesters[0], - focusRequesters[dialogItems.lastIndex], - ), - ) - } - } + is DialogItem -> + ListItem( + selected = false, + enabled = !waiting && it.enabled, + onClick = { + if (dismissOnClick) { + onDismissRequest.invoke() + } + it.onClick.invoke() + }, + headlineContent = it.headlineContent, + overlineContent = it.overlineContent, + supportingContent = it.supportingContent, + leadingContent = it.leadingContent, + trailingContent = it.trailingContent, + modifier = Modifier, + ) } } } @@ -339,7 +290,7 @@ fun DialogPopup( onDismissRequest: () -> Unit, dismissOnClick: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 1.dp, + elevation: Dp = 3.dp, ) = DialogPopup( showDialog = true, waitToLoad = params.fromLongClick, @@ -357,9 +308,6 @@ fun DialogPopup( @Composable fun ScrollableDialog( onDismissRequest: () -> Unit, - width: Dp = 600.dp, - maxHeight: Dp = 380.dp, - itemSpacing: Dp = 8.dp, content: LazyListScope.() -> Unit, ) { val scrollAmount = 100f @@ -381,14 +329,13 @@ fun ScrollableDialog( LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(itemSpacing), + verticalArrangement = Arrangement.spacedBy(8.dp), content = content, modifier = Modifier - .width(width) - .heightIn(max = maxHeight) + .width(600.dp) + .heightIn(max = 380.dp) .focusable() - .shadow(elevation = 8.dp, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), shape = RoundedCornerShape(8.dp), @@ -417,7 +364,7 @@ fun ScrollableDialog( fun BasicDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 1.dp, + elevation: Dp = 3.dp, content: @Composable () -> Unit, ) { Dialog( @@ -427,7 +374,6 @@ fun BasicDialog( Box( modifier = Modifier - .shadow(elevation = elevation, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), shape = RoundedCornerShape(8.dp), @@ -448,16 +394,13 @@ fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 1.dp, - bodyColor: Color = MaterialTheme.colorScheme.onSurface, - @StringRes cancelStringRes: Int = R.string.cancel, - @StringRes confirmStringRes: Int = R.string.confirm, + elevation: Dp = 3.dp, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier) }, ) @@ -471,9 +414,6 @@ fun ConfirmDialogContent( onCancel: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, - bodyColor: Color = MaterialTheme.colorScheme.onSurface, - @StringRes cancelStringRes: Int = R.string.cancel, - @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -493,7 +433,7 @@ fun ConfirmDialogContent( item { Text( text = body, - color = bodyColor, + color = MaterialTheme.colorScheme.onSurface, ) } } @@ -503,89 +443,19 @@ fun ConfirmDialogContent( horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth(), ) { - TextButton( - stringRes = cancelStringRes, + Button( onClick = onCancel, - ) - TextButton( - stringRes = confirmStringRes, - onClick = onConfirm, - ) - } - } - } -} - -@Composable -fun ConfirmDeleteDialog( - itemTitle: String, - onCancel: () -> Unit, - onConfirm: () -> Unit, - elevation: Dp = 3.dp, -) { - BasicDialog( - onDismissRequest = onCancel, - properties = DialogProperties(usePlatformDefaultWidth = false), - elevation = elevation, - ) { - LazyColumn( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(16.dp), - modifier = Modifier.wrapContentWidth(), - ) { - item { - Text( - text = stringResource(R.string.delete_item), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - item { - Text( - text = itemTitle, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .padding(bottom = 8.dp), - ) - } - - item { - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), - modifier = Modifier, ) { - Button( - onClick = onCancel, - modifier = Modifier.width(72.dp), - ) { - Text( - text = stringResource(R.string.cancel), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - Button( - onClick = onConfirm, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 4.dp), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = Color.Red.copy(alpha = .8f), - modifier = Modifier, - ) - Text( - text = stringResource(R.string.confirm), - ) - } - } + Text( + text = stringResource(R.string.cancel), + ) + } + Button( + onClick = onConfirm, + ) { + Text( + text = stringResource(R.string.confirm), + ) } } } @@ -593,17 +463,15 @@ fun ConfirmDeleteDialog( } fun chooseVersionParams( - resources: Resources, + context: Context, sources: List<MediaSourceInfo>, - chosenSourceId: UUID?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( fromLongClick = false, - title = resources.getString(R.string.choose_stream, resources.getString(R.string.version)), + title = context.getString(R.string.choose_stream, context.getString(R.string.version)), items = sources.filter { it.id.isNotNullOrBlank() }.mapIndexed { index, source -> - val uuid = source.id?.toUUIDOrNull() val videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } val title = source.name ?: source.path ?: source.id ?: "" @@ -611,28 +479,8 @@ fun chooseVersionParams( headlineContent = { Text(text = title) }, - leadingContent = { - SelectedLeadingContent(uuid != null && uuid == chosenSourceId) - }, supportingContent = { - 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) + videoStream?.displayTitle?.let { Text(text = it) } }, onClick = { onClick.invoke(index) }, ) @@ -651,101 +499,40 @@ fun resourceFor(type: MediaStreamType): Int = } fun chooseStream( - resources: Resources, + context: Context, streams: List<MediaStream>, - streamChoice: StreamChoiceResult?, type: MediaStreamType, - preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, -): DialogParams { - val filteredStreams = streams.filter { it.type == type } - val currentIndex = streamChoice?.stream?.index - return DialogParams( +): DialogParams = + DialogParams( fromLongClick = false, - title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), + title = context.getString(R.string.choose_stream, context.getString(resourceFor(type))), items = buildList { if (type == MediaStreamType.SUBTITLE) { add( DialogItem( - selected = currentIndex == null, - leadingContent = { - SelectedLeadingContent(currentIndex == null) - }, headlineContent = { Text(text = stringResource(R.string.none)) }, supportingContent = { }, - trailingContent = - if (currentIndex == null && streamChoice != null) { - { - Text( - text = streamChoice.reason.description.getString(), - ) - } - } else { - null - }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) - add( + } + addAll( + streams.filter { it.type == type }.mapIndexed { index, stream -> + val title = stream.displayTitle ?: stream.title ?: "" DialogItem( - leadingContent = { - SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) - }, headlineContent = { - Text(text = stringResource(R.string.only_forced_subtitles)) + Text(text = title) }, supportingContent = { }, - onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, - ), - ) - if (filteredStreams.isNotEmpty()) { - add(DialogItemDivider) - } - } - addAll( - filteredStreams - .let { - if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { - it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } - } else { - it - } - }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(resources, stream, true) - DialogItem( - selected = currentIndex == stream.index, - leadingContent = { - SelectedLeadingContent(currentIndex == stream.index) - }, - headlineContent = { - Text( - text = - simpleStream.streamTitle - ?: simpleStream.displayTitle, - ) - }, - supportingContent = { - if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) - }, - trailingContent = - if (currentIndex == stream.index) { - { - Text( - text = streamChoice.reason.description.getString(), - ) - } - } else { - null - }, - onClick = { onClick.invoke(stream.index) }, - ) - }, + onClick = { onClick.invoke(stream.index) }, + ) + }, ) }, ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt new file mode 100644 index 00000000..eb206f03 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +@Composable +fun DotSeparatedRow( + texts: List<String>, + rating: Float? = null, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + CompositionLocalProvider(LocalTextStyle provides textStyle) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + texts.forEachIndexed { index, text -> + Text( + text = text, + style = textStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + if (rating != null || index != texts.lastIndex) { + Box( + modifier = + Modifier + .padding(horizontal = 8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) + .size(4.dp), + ) + } + } + rating?.let { + SimpleStarRating( + communityRating = it, + modifier = Modifier, + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun DotSeparatedRowPreview() { + WholphinTheme { + DotSeparatedRow( + texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), + rating = 7.5f, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt index 47b4ebc9..c8b6796b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/EditTextBox.kt @@ -1,27 +1,14 @@ package com.github.damontecres.wholphin.ui.components -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.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicSecureTextField import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.KeyboardActionHandler -import androidx.compose.foundation.text.input.TextFieldDecorator -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search @@ -30,10 +17,9 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults.Container import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue 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.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -43,165 +29,11 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -/** - * An input field for text customized for TV entry - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditTextBox( - state: TextFieldState, - modifier: Modifier = Modifier, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - onKeyboardAction: KeyboardActionHandler? = null, - leadingIcon: @Composable (() -> Unit)? = null, - enabled: Boolean = true, - readOnly: Boolean = false, - maxLines: Int = 1, - isInputValid: (String) -> Boolean = { true }, - isPassword: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val focused by interactionSource.collectIsFocusedAsState() - val colors = - TextFieldDefaults.colors( - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), - disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f), - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), - errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f), - errorTextColor = MaterialTheme.colorScheme.onErrorContainer, - ) - val textColor = - when { - !isInputValid(state.text.toString()) -> colors.errorTextColor - focused -> colors.focusedTextColor - !enabled -> colors.disabledTextColor - !focused -> colors.unfocusedTextColor - else -> colors.unfocusedTextColor - } - val lineLimits = - remember(maxLines) { - if (maxLines == 1) { - TextFieldLineLimits.SingleLine - } else { - TextFieldLineLimits.MultiLine( - maxLines, - maxLines, - ) - } - } - val decorator = - remember { - TextFieldDecorator { innerTextField: @Composable () -> Unit -> - val containerColor = - when { - !isInputValid(state.text.toString()) -> colors.errorContainerColor - focused -> colors.focusedContainerColor - !enabled -> colors.disabledContainerColor - !focused -> colors.unfocusedContainerColor - else -> colors.unfocusedContainerColor - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .background( - containerColor, - shape = if (maxLines > 1) RoundedCornerShape(8.dp) else CircleShape, - ).padding(8.dp), - ) { - CompositionLocalProvider( - androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, - ) { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { - leadingIcon?.invoke() - innerTextField.invoke() - } - } - } - } - } - - if (isPassword) { - BasicSecureTextField( - state = state, - modifier = - modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - ), - enabled = enabled, - readOnly = readOnly, - keyboardOptions = keyboardOptions, - onKeyboardAction = onKeyboardAction, - textStyle = MaterialTheme.typography.bodyLarge.merge(textColor), - interactionSource = interactionSource, - cursorBrush = SolidColor(colors.cursorColor), - decorator = decorator, - ) - } else { - BasicTextField( - state = state, - modifier = - modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - ), - enabled = enabled, - readOnly = readOnly, - keyboardOptions = keyboardOptions, - onKeyboardAction = onKeyboardAction, - textStyle = MaterialTheme.typography.bodyLarge.merge(textColor), - interactionSource = interactionSource, - lineLimits = lineLimits, - cursorBrush = SolidColor(colors.cursorColor), - decorator = decorator, - ) - } -} - -/** - * And [EditTextBox] styles for searches - */ -@Composable -fun SearchEditTextBox( - state: TextFieldState, - onSearchClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - EditTextBox( - state = state, - modifier = modifier, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - imeAction = ImeAction.Search, - ), - onKeyboardAction = { - onSearchClick.invoke() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - }, - enabled = enabled, - readOnly = readOnly, - interactionSource = interactionSource, - ) -} +import com.google.protobuf.value /** * A modified [BasicTextField] that looks & fits better with TV material controls @@ -221,18 +53,19 @@ fun EditTextBox( isInputValid: (String) -> Boolean = { true }, supportingText: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val interactionSource = remember { MutableInteractionSource() } + // From ButtonDefaults val colors = TextFieldDefaults.colors( - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - focusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), - disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f), - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), - errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.75f), - errorTextColor = MaterialTheme.colorScheme.onErrorContainer, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + focusedTextColor = MaterialTheme.colorScheme.inverseOnSurface, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + focusedIndicatorColor = MaterialTheme.colorScheme.border, + unfocusedLabelColor = Color.Unspecified, ) CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) { BasicTextField( @@ -324,7 +157,6 @@ fun SearchEditTextBox( enabled: Boolean = true, readOnly: Boolean = false, height: Dp = 40.dp, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { EditTextBox( value, @@ -339,7 +171,7 @@ fun SearchEditTextBox( KeyboardActions( onSearch = { onSearchClick.invoke() - this.defaultKeyboardAction(ImeAction.Search) + this.defaultKeyboardAction(ImeAction.Done) }, ), leadingIcon = { @@ -349,10 +181,9 @@ fun SearchEditTextBox( tint = MaterialTheme.colorScheme.onPrimaryContainer, ) }, - enabled = enabled, - readOnly = readOnly, - height = height, - interactionSource = interactionSource, + enabled, + readOnly, + height, ) } @@ -360,39 +191,16 @@ fun SearchEditTextBox( @Composable private fun EditTextBoxPreview() { WholphinTheme { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - EditTextBox( - state = rememberTextFieldState("string"), - ) + Column { EditTextBox( value = "string", onValueChange = {}, ) SearchEditTextBox( - state = rememberTextFieldState("search query"), + value = "search query", + onValueChange = {}, onSearchClick = { }, ) - EditTextBox( - state = rememberTextFieldState("string\nstring\nstring"), - maxLines = 5, -// height = 88.dp, - ) - EditTextBox( - state = rememberTextFieldState("search"), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - }, - isInputValid = { true }, - modifier = Modifier.width(280.dp), - ) - EditTextBox( - state = rememberTextFieldState("password"), - isPassword = true, - ) } } } 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 b1995b3d..f833e228 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,5 +1,6 @@ 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 @@ -9,25 +10,30 @@ 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.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -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.ui.detail.DebugViewModel.Companion.sendAppLogs 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( - private val serverReportService: ServerReportService, + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val clientInfo: ClientInfo, + private val deviceInfo: DeviceInfo, ) : ViewModel() { fun sendLogs() { - viewModelScope.launchIO { serverReportService.sendAppLogs() } + sendAppLogs(context, api, clientInfo, deviceInfo) } } @@ -50,12 +56,15 @@ fun ErrorMessage( color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleMedium, ) - TextButton( - stringRes = R.string.send_app_logs, + Button( onClick = { viewModel.sendLogs() }, - ) + ) { + Text( + text = stringResource(R.string.send_app_logs), + ) + } message?.let { Text( text = it, @@ -93,13 +102,3 @@ fun ErrorMessage( exception = error.exception, modifier = modifier, ) - -@Composable -fun ErrorMessage( - error: DataLoadingState.Error, - modifier: Modifier = Modifier, -) = ErrorMessage( - message = error.message, - exception = error.exception, - modifier = modifier, -) 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 af64ef15..b6dd6474 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 @@ -4,13 +4,9 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete @@ -22,35 +18,24 @@ 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.graphics.Color +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button import androidx.tv.material3.Icon 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 @@ -58,24 +43,14 @@ 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 com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.DefaultButtonPadding import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import java.util.UUID -/** - * Button for filtering data. - * - * Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked. - * - * @see GetItemsFilter - * @see ItemFilterBy - */ @Composable fun FilterByButton( filterOptions: List<ItemFilterBy<*>>, @@ -83,7 +58,6 @@ 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) } @@ -92,10 +66,7 @@ fun FilterByButton( Box(modifier = modifier) { ExpandableFilterButton( filterCount = filterCount, - onClick = { - onShow?.invoke(true) - dropDown = true - }, + onClick = { dropDown = true }, modifier = Modifier, ) @@ -103,7 +74,6 @@ fun FilterByButton( expanded = dropDown, containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), onDismissRequest = { - onShow?.invoke(false) dropDown = false nestedDropDown = null }, @@ -224,9 +194,7 @@ fun FilterByButton( val isSelected = remember(currentValue) { when (filterOption) { - GenreFilter, - StudioFilter, - -> { + GenreFilter -> { (currentValue as? List<UUID>) .orEmpty() .contains(value.value) @@ -234,9 +202,7 @@ fun FilterByButton( FavoriteFilter, PlayedFilter, - -> { - (currentValue as? Boolean) == value.name.toBoolean() - } + -> (currentValue as? Boolean) == value.name.toBoolean() OfficialRatingFilter -> { (currentValue as? List<String>) @@ -244,23 +210,19 @@ fun FilterByButton( .contains(value.name) } - VideoTypeFilter -> { + VideoTypeFilter -> (currentValue as? List<FilterVideoType>) .orEmpty() .contains(value.value) - } YearFilter, DecadeFilter, - -> { + -> (currentValue as? List<Int>) .orEmpty() .contains(value.value) - } - CommunityRatingFilter -> { - (currentValue as? Int) == value.value - } + CommunityRatingFilter -> (currentValue as? Int) == value.value } } val interactionSource = remember { MutableInteractionSource() } @@ -291,9 +253,7 @@ fun FilterByButton( onClick = { val newFilter = when (filterOption) { - GenreFilter, - StudioFilter, - -> { + GenreFilter -> { val list = (currentValue as? List<UUID>).orEmpty() val newValue = list @@ -364,12 +324,11 @@ fun FilterByButton( filterOption.set(newValue, current) } - CommunityRatingFilter -> { + CommunityRatingFilter -> filterOption.set( value.value as? Int, current, ) - } } onFilterChange.invoke(newFilter) @@ -394,344 +353,35 @@ fun ExpandableFilterButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, iconColor: Color = Color.Unspecified, ) { + val resources = LocalResources.current val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = onClick, - modifier = - modifier.requiredSizeIn( - minWidth = MinButtonSize, - minHeight = MinButtonSize, - maxHeight = MinButtonSize, - ), - contentPadding = PaddingValues(0.dp), // DefaultButtonPadding, + modifier = modifier.heightIn(min = 40.dp), + contentPadding = DefaultButtonPadding, interactionSource = interactionSource, ) { - Box( - modifier = - Modifier - .fillMaxHeight() - .widthIn(min = if (filterCount > 0 || isFocused) (MinButtonSize - 12.dp) else MinButtonSize), - ) { - Text( - text = stringResource(R.string.fa_filter), - style = MaterialTheme.typography.titleSmall, - color = iconColor, - fontSize = 16.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center), - ) - } + Text( + text = stringResource(R.string.fa_filter), + style = MaterialTheme.typography.titleSmall, + color = iconColor, + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier, + ) AnimatedVisibility(filterCount > 0) { Text( text = filterCount.toString(), style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(end = 4.dp), + modifier = Modifier.padding(start = 4.dp), ) } AnimatedVisibility(isFocused) { Text( text = stringResource(R.string.filter), style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(start = 4.dp, end = 4.dp), - ) - } - } -} - -// 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() { - WholphinTheme { - Column { - ExpandableFilterButton( - filterCount = 2, - onClick = {}, - ) - - ExpandableFilterButton( - filterCount = 0, - onClick = {}, + modifier = Modifier.padding(start = 4.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt deleted file mode 100644 index 811b0051..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text - -/** - * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled. - */ -@Composable -@NonRestartableComposable -fun FocusableItemRow( - title: String, - subtitle: String, - modifier: Modifier = Modifier, - isError: Boolean = false, -) = FocusableItemRow( - titleContent = { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - }, - subtitleContent = { - Text( - text = subtitle, - style = MaterialTheme.typography.titleMedium, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), - ) - }, - modifier = modifier, -) - -@Composable -fun FocusableItemRow( - titleContent: @Composable () -> Unit, - subtitleContent: @Composable () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - val background by animateColorAsState( - if (focused) { - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Transparent - }, - ) - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .padding(start = 8.dp) - .focusable(interactionSource = interactionSource) - .background(background, shape = RoundedCornerShape(8.dp)) - .padding(8.dp), - ) { - titleContent.invoke() - subtitleContent.invoke() - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ecc643fa..40e74e8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -5,344 +5,132 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData 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.data.model.GetItemsFilter import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.GenreCard +import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.CardGridItem -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.ApiRequestPager 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 com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch 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 import org.jellyfin.sdk.model.api.request.GetGenresRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import kotlin.time.Duration.Companion.hours +import javax.inject.Inject -@HiltViewModel(assistedFactory = GenreViewModel.Factory::class) +@HiltViewModel class GenreViewModel - @AssistedInject + @Inject constructor( private val api: ApiClient, - private val imageUrlService: ImageUrlService, - private val serverRepository: ServerRepository, val navigationManager: NavigationManager, - @Assisted private val itemId: UUID, - @Assisted private val includeItemTypes: List<BaseItemKind>?, ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create( - itemId: UUID, - includeItemTypes: List<BaseItemKind>?, - ): GenreViewModel - } + private lateinit var itemId: UUID + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + val genres = MutableLiveData<List<BaseItem?>>(listOf()) - private val _state = MutableStateFlow(GenreGridState()) - val state: StateFlow<GenreGridState> = _state - - fun init(cardWidthPx: Int) { - _state.update { it.copy(item = DataLoadingState.Loading) } - viewModelScope.launchIO { - try { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - val request = - GetGenresRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val genres = - GetGenresRequestHandler - .execute(api, request) - .content.items - .map { - Genre( - id = it.id, - name = it.name ?: "", - imageUrl = null, - sortName = it.alphabetSortName, - ) - } - _state.update { - it.copy( - item = DataLoadingState.Success(item), - genres = genres, - ) - } - val genreToUrl = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser?.id, - scope = viewModelScope, - imageUrlService = imageUrlService, - genres = genres.map { it.id }, - parentId = itemId, - includeItemTypes = includeItemTypes, - cardWidthPx = cardWidthPx, - ) - val genresWithImages = - genres.map { - it.copy( - imageUrl = genreToUrl[it.id], - ) - } - _state.update { - it.copy( - genres = genresWithImages, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching genres") - _state.update { it.copy(item = DataLoadingState.Error(ex)) } + fun init(itemId: UUID) { + loading.value = LoadingState.Loading + this.itemId = itemId + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { + val request = + GetGenresRequest( + parentId = itemId, + fields = SlimItemFields, + ) + val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() + withContext(Dispatchers.Main) { + genres.value = pager + loading.value = LoadingState.Success } } } suspend fun positionOfLetter(letter: Char): Int = - withContext(WholphinDispatchers.IO) { - GetGenresRequestHandler.countMatching( - api = api, - request = - GetGenresRequest( - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ), - ) + withContext(Dispatchers.IO) { + val request = + GetGenresRequest( + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetGenresRequestHandler.execute(api, request) + return@withContext result.totalRecordCount } } -data class GenreGridState( - val item: DataLoadingState<BaseItem> = DataLoadingState.Pending, - val genres: List<Genre> = emptyList(), -) - -data class GenreCacheKey( - val userId: UUID?, - val parentId: UUID, -) - -private val genreCache by lazy { - InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) { - expireAfterWriteDuration = 2.hours - } -} - -/** - * Create a mapping from genre IDs to image URLs using random items within each genre - */ -suspend fun getGenreImageMap( - api: ApiClient, - userId: UUID?, - scope: CoroutineScope, - imageUrlService: ImageUrlService, - genres: List<UUID>, - parentId: UUID, - includeItemTypes: List<BaseItemKind>?, - cardWidthPx: Int?, - useCache: Boolean = true, -): Map<UUID, String?> { - val key = GenreCacheKey(userId, parentId) - if (useCache) { - genreCache.getIfAvailable(key)?.let { - Timber.v("Got cached entry") - return it - } - } - val genreToUrl = ConcurrentHashMap<UUID, String?>() - val semaphore = Semaphore(4) - genres - .map { genreId -> - scope.async(WholphinDispatchers.IO) { - semaphore.withPermit { - val item = - GetItemsRequestHandler - .execute( - api, - GetItemsRequest( - userId = userId, - parentId = parentId, - recursive = true, - limit = 1, - sortBy = listOf(ItemSortBy.RANDOM), - fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.BACKDROP), - imageTypeLimit = 1, - includeItemTypes = includeItemTypes, - genreIds = listOf(genreId), - enableTotalRecordCount = false, - ), - ).content.items - .firstOrNull() - if (item != null) { - genreToUrl[genreId] = - imageUrlService.getItemImageUrl( - itemId = item.id, - itemType = item.type, - seriesId = null, - useSeriesForPrimary = true, - imageType = ImageType.BACKDROP, - imageTags = item.imageTags.orEmpty(), - fillWidth = cardWidthPx, - backdropTags = item.backdropImageTags.orEmpty(), - ) - } - } - } - }.awaitAll() - genreCache.put(key, genreToUrl) - return genreToUrl -} - -@Stable -data class Genre( - val id: UUID, - val name: String, - val imageUrl: String?, - override val sortName: String = name, -) : CardGridItem { - override val gridId: String get() = id.toString() - override val playable: Boolean = false -} - -/** - * Show an optimized grid of genres for a library - */ @Composable fun GenreCardGrid( itemId: UUID, - includeItemTypes: List<BaseItemKind>?, - collectionType: CollectionType, modifier: Modifier = Modifier, - initialPosition: Int = 0, - viewModel: GenreViewModel = - hiltViewModel<GenreViewModel, GenreViewModel.Factory>( - creationCallback = { it.create(itemId, includeItemTypes) }, - ), + viewModel: GenreViewModel = hiltViewModel(), ) { - val columns = 4 - val spacing = 16.dp - val density = LocalDensity.current - val configuration = LocalConfiguration.current - val cardWidthPx = - 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 - // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required - (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) - .div(columns) - .roundToPx() - } - } OneTimeLaunchedEffect { - viewModel.init(cardWidthPx) + viewModel.init(itemId) } - val state by viewModel.state.collectAsState() + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val genres by viewModel.genres.observeAsState(listOf()) val gridFocusRequester = remember { FocusRequester() } - when (val st = state.item) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier.focusable()) - } + when (val st = loading) { + LoadingState.Pending, + LoadingState.Loading, + -> LoadingPage(modifier.focusable()) - is DataLoadingState.Error -> { - ErrorMessage(st, modifier.focusable()) - } + is LoadingState.Error -> ErrorMessage(st, modifier.focusable()) - is DataLoadingState.Success<BaseItem> -> { + LoadingState.Success -> Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - val item = st.data CardGrid( - pager = state.genres, + pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( - createGenreDestination( - genreId = genre.id, - genreName = genre.name, - parentId = itemId, - parentName = item.title, - includeItemTypes = includeItemTypes, - collectionType = collectionType, + Destination.FilteredCollection( + itemId = itemId, + filter = GetItemsFilter(genres = listOf(genre.id)), + recursive = true, ), ) }, onLongClickItem = { _, _ -> }, - onClickPlay = { _, _ -> }, letterPosition = { viewModel.positionOfLetter(it) }, gridFocusRequester = gridFocusRequester, showJumpButtons = false, showLetterButtons = true, modifier = Modifier.fillMaxSize(), - initialPosition = initialPosition, + initialPosition = 0, positionCallback = { columns, position -> }, - columns = columns, - spacing = spacing, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GenreCard( - genre = item, + cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + GridCard( + item = item, onClick = onClick, onLongClick = onLongClick, modifier = mod, + imageAspectRatio = 1f, ) }, ) } - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt deleted file mode 100644 index c697b667..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreText.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text - -private const val MAX_TO_SHOW = 4 - -/** - * Display a comma separated list of genres - * - * Only the first few will be shown - */ -@Composable -fun GenreText( - genres: List<String>, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.bodyMedium, - color: Color = MaterialTheme.colorScheme.onSurface, -) { - val text = - remember(genres) { - if (genres.size <= MAX_TO_SHOW) { - genres.joinToString(", ") - } else { - genres - .subList(0, MAX_TO_SHOW) - .joinToString(", ") + ", +${genres.size - MAX_TO_SHOW}" - } - } - Text( - text = text, - style = textStyle, - color = color, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt deleted file mode 100644 index 0ebdd1db..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HeaderUtils.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -object HeaderUtils { - val topPadding = 48.dp - val bottomPadding = 32.dp - val startPadding = 8.dp - - val padding = PaddingValues(top = topPadding, bottom = bottomPadding, start = startPadding) - - val height = 180.dp - - val logoHeight = 60.dp - - val modifier = - Modifier - .padding(padding) - .height(height) -} 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 deleted file mode 100644 index f86868bc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/HiddenFocusBox.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.unit.dp - -/** - * An invisible, no size Box which can be focused. - * - * Used to allow for transitioning to restore hidden content - */ -@Composable -fun HiddenFocusBox( - focusRequester: FocusRequester = remember { FocusRequester() }, - onFocus: () -> Unit, -) = Box( - modifier = - Modifier - .fillMaxWidth() - .height(0.dp) - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) onFocus.invoke() - }.focusable(), -) 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 3609cdfa..a73d9106 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,255 +1,142 @@ 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.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text 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.AspectRatios 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 com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import timber.log.Timber -import java.util.UUID +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest @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(), - ContextMenuProvider { + @Assisted private val destination: Destination.ItemGrid, + ) : ViewModel() { + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + val items = MutableLiveData<List<BaseItem?>>(listOf()) + @AssistedFactory interface Factory { - fun create(destination: Destination.ItemGrid<*>): ItemGridViewModel + fun create(destination: Destination.ItemGrid): ItemGridViewModel } - private val _state = MutableStateFlow(ItemGridState()) - val state: StateFlow<ItemGridState> = _state - init { - viewModelScope.launchIO { - try { - val request = destination.request as Any - val pager = - ApiRequestPager( - api, - request, - destination.requestHandler as RequestHandler<Any>, - viewModelScope, - useSeriesForPrimary = true, - ).init() - if (pager.isNotEmpty()) { - pager.getBlocking(0) - } - _state.update { - it.copy(items = DataLoadingState.Success(pager)) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error fetching items") - _state.update { it.copy(items = DataLoadingState.Error(ex)) } + viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) { + val request = + GetItemsRequest( + ids = destination.itemIds, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ) + val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } + withContext(Dispatchers.Main) { + this@ItemGridViewModel.items.value = pager + this@ItemGridViewModel.loading.value = LoadingState.Success } } } - override fun navigateTo(destination: Destination) { + 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( - val items: DataLoadingState<List<BaseItem?>> = DataLoadingState.Pending, -) - -/** - * Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem] - */ @Composable fun ItemGrid( - preferences: UserPreferences, - destination: Destination.ItemGrid<*>, + 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 loading by viewModel.loading.observeAsState(LoadingState.Loading) + val items by viewModel.items.observeAsState(listOf()) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() - val state by viewModel.state.collectAsState() - when (val st = state.items) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<List<BaseItem?>> -> { - var position by rememberInt(destination.initialPosition) + LoadingState.Success -> { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column(modifier = modifier) { - GridTitle(destination.title.getString()) - + Text( + text = destination.title ?: destination.titleRes?.let { stringResource(it) } ?: "", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) CardGrid( - pager = st.data, + pager = items, onClickItem = { index: Int, item: BaseItem -> - position = index - viewModel.navigateTo(item.destination(index)) + // TODO handle more types + viewModel.navigateTo(Destination.Playback(item.id, 0)) }, - onLongClickItem = { index: Int, item: BaseItem -> - position = index - contextMenu.showContextMenu(index, item) - }, - onClickPlay = { _, item -> viewModel.navigateTo(Destination.Playback(item)) }, + onLongClickItem = { index: Int, item: BaseItem -> }, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, showLetterButtons = false, - initialPosition = destination.initialPosition, - spacing = destination.viewOptions.spacing.dp, - cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> + spacing = 24.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> GridCard( item = item, onClick = onClick, onLongClick = onLongClick, - fillWidth = widthPx, modifier = mod, - imageAspectRatio = destination.viewOptions.aspectRatio.ratio, + imageAspectRatio = AspectRatios.WIDE, // TODO ) }, - columns = destination.viewOptions.columns, + columns = 3, modifier = Modifier.fillMaxSize(), ) } } } - contextMenu.Compose() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt index 6a2300c2..67c56f12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LicenseInfo.kt @@ -6,9 +6,6 @@ import androidx.compose.ui.Modifier import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer -/** - * Displays dependencies' license information to comply with attribution - */ @Composable fun LicenseInfo(modifier: Modifier = Modifier) { val libraries by produceLibraries() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt deleted file mode 100644 index bdfddb9e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.tv.material3.DenseListItem -import androidx.tv.material3.ListItem -import androidx.tv.material3.ListItemBorder -import androidx.tv.material3.ListItemColors -import androidx.tv.material3.ListItemDefaults -import androidx.tv.material3.ListItemGlow -import androidx.tv.material3.ListItemScale -import androidx.tv.material3.ListItemShape - -/** - * Displays either a [ListItem] or [DenseListItem] based on the dense parameter - */ -@Composable -@NonRestartableComposable -fun ListItemWrapper( - dense: Boolean, - selected: Boolean, - onClick: () -> Unit, - headlineContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onLongClick: (() -> Unit)? = null, - overlineContent: (@Composable () -> Unit)? = null, - supportingContent: (@Composable () -> Unit)? = null, - leadingContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null, - tonalElevation: Dp = ListItemDefaults.TonalElevation, - shape: ListItemShape = ListItemDefaults.shape(), - colors: ListItemColors = ListItemDefaults.colors(), - scale: ListItemScale = ListItemDefaults.scale(), - border: ListItemBorder = ListItemDefaults.border(), - glow: ListItemGlow = ListItemDefaults.glow(), - interactionSource: MutableInteractionSource? = null, -) = if (dense) { - DenseListItem( - selected = selected, - onClick = onClick, - headlineContent = headlineContent, - modifier = modifier, - enabled = enabled, - onLongClick = onLongClick, - overlineContent = overlineContent, - supportingContent = supportingContent, - leadingContent = leadingContent, - trailingContent = trailingContent, - tonalElevation = tonalElevation, - shape = shape, - colors = colors, - scale = scale, - border = border, - glow = glow, - interactionSource = interactionSource, - ) -} else { - ListItem( - selected = selected, - onClick = onClick, - headlineContent = headlineContent, - modifier = modifier, - enabled = enabled, - onLongClick = onLongClick, - overlineContent = overlineContent, - supportingContent = supportingContent, - leadingContent = leadingContent, - trailingContent = trailingContent, - tonalElevation = tonalElevation, - shape = shape, - colors = colors, - scale = scale, - border = border, - glow = glow, - interactionSource = interactionSource, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt index 7d88aac9..b915fdc1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt @@ -59,24 +59,22 @@ fun LoadingRow( }, ) { when (val r = state) { - is RowLoadingState.Error -> { + is RowLoadingState.Error -> LoadingRowPlaceholder( title = title, message = r.localizedMessage, messageColor = MaterialTheme.colorScheme.error, modifier = Modifier, ) - } RowLoadingState.Pending, RowLoadingState.Loading, - -> { + -> LoadingRowPlaceholder( title = title, message = stringResource(R.string.loading), modifier = modifier, ) - } is RowLoadingState.Success -> { if (r.items.isNotEmpty()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt index 874585de..7417028f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/OverviewText.kt @@ -23,9 +23,6 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus -/** - * Show the overview text for an item. Uses a fixed size and allows for clicking. - */ @Composable fun OverviewText( overview: String, 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 4137759d..6790e627 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -2,36 +2,22 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusState @@ -40,32 +26,21 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button import androidx.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.ui.DefaultButtonPadding 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 @@ -74,35 +49,17 @@ import kotlin.time.Duration.Companion.seconds */ @Composable fun ExpandablePlayButtons( - title: String, resumePosition: Duration, watched: Boolean, favorite: Boolean, - canDelete: Boolean, - trailers: List<Trailer>?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, 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), @@ -111,52 +68,38 @@ 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 = + if (resumePosition > Duration.ZERO) { + item("play") { + ExpandablePlayButton( + R.string.resume, + resumePosition, + Icons.Default.PlayArrow, + playOnClick, Modifier .onFocusChanged(buttonOnFocusChanged) .focusRequester(firstFocus), - ) - } - if (resumePosition > Duration.ZERO) { - item("restart") { - ExpandablePlayButton( - title = R.string.restart, - resume = Duration.ZERO, - icon = Icons.Default.Refresh, - onClick = playOnClick, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - mirrorIcon = true, - interactionSource = restartInteractionSource, ) } - } - - 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), + item("restart") { + ExpandablePlayButton( + R.string.restart, + Duration.ZERO, + Icons.Default.Refresh, + playOnClick, + Modifier.onFocusChanged(buttonOnFocusChanged), + mirrorIcon = true, + ) + } + } else { + item("play") { + ExpandablePlayButton( + R.string.play, + Duration.ZERO, + Icons.Default.PlayArrow, + playOnClick, + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus), ) } } @@ -182,52 +125,19 @@ fun ExpandablePlayButtons( ) } - if (trailers != null) { - item("trailers") { - TrailerButton( - trailers = trailers, - trailerOnClick = trailerOnClick, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - } - if (canDelete) { - item("delete") { - DeleteButton( - title = title, - onConfirmDelete = onConfirmDelete, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } - } - // More button item("more") { ExpandablePlayButton( - title = R.string.more, - resume = Duration.ZERO, - icon = Icons.Default.MoreVert, - onClick = { moreOnClick.invoke() }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + R.string.more, + Duration.ZERO, + Icons.Default.MoreVert, + { moreOnClick.invoke() }, + Modifier.onFocusChanged(buttonOnFocusChanged), ) } } - chooseVersion?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { chooseVersion = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } } -val MinButtonSize = 40.dp - /** * An icon button typically used in a row for playing media * @@ -242,93 +152,24 @@ fun ExpandablePlayButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, - enabled: Boolean = true, -) = ExpandablePlayButton( - title = title, - resume = resume, - icon = { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(28.dp) - .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), - ) - }, - onClick = onClick, - modifier = modifier, - interactionSource = interactionSource, - enabled = enabled, -) - -@Composable -fun ExpandablePlayButton( - @StringRes title: Int, - resume: Duration, - icon: Painter, - onClick: (position: Duration) -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - mirrorIcon: Boolean = false, - enabled: Boolean = true, -) = ExpandablePlayButton( - title = title, - resume = resume, - icon = { - Icon( - painter = icon, - contentDescription = null, - modifier = - Modifier - .size(28.dp) - .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), - ) - }, - onClick = onClick, - modifier = modifier, - interactionSource = interactionSource, - enabled = enabled, -) - -@Composable -fun ExpandablePlayButton( - @StringRes title: Int, - resume: Duration, - icon: @Composable BoxScope.() -> Unit, - onClick: (position: Duration) -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = { onClick.invoke(resume) }, - enabled = enabled, - modifier = - modifier.requiredSizeIn( - minWidth = MinButtonSize, - minHeight = MinButtonSize, - maxHeight = MinButtonSize, - ), + modifier = modifier, contentPadding = DefaultButtonPadding, interactionSource = interactionSource, ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .padding(start = 2.dp) - .height(MinButtonSize), - ) { - icon.invoke(this) - } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) Text( text = stringResource(title), style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(end = 4.dp), ) } } @@ -345,142 +186,38 @@ fun ExpandableFaButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, iconColor: Color = Color.Unspecified, - enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = onClick, - enabled = enabled, - modifier = - modifier.requiredSizeIn( - minWidth = MinButtonSize, - minHeight = MinButtonSize, - maxHeight = MinButtonSize, - ), + modifier = modifier.heightIn(min = 40.dp), contentPadding = DefaultButtonPadding, interactionSource = interactionSource, ) { - Box( - modifier = - Modifier - .size(32.dp), - ) { - Text( - text = stringResource(iconStringRes), - style = MaterialTheme.typography.titleSmall, - color = iconColor, - fontSize = 16.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center), - ) - } + Text( + text = stringResource(iconStringRes), + style = MaterialTheme.typography.titleSmall, + color = iconColor, + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier, + ) AnimatedVisibility(isFocused) { Text( text = stringResource(title), style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(end = 4.dp), + modifier = Modifier.padding(start = 4.dp), ) } } } -@Composable -fun TrailerButton( - trailers: List<Trailer>, - trailerOnClick: (Trailer) -> Unit, - modifier: Modifier = Modifier, -) { - var showDialog by remember { mutableStateOf(false) } - ExpandableFaButton( - title = - if (trailers.isEmpty()) { - R.string.no_trailers - } else if (trailers.size == 1) { - R.string.play_trailer - } else { - R.string.trailers_title - }, - iconStringRes = R.string.fa_film, - enabled = trailers.isNotEmpty(), - onClick = { - if (trailers.size == 1) { - trailerOnClick.invoke(trailers.first()) - } else { - showDialog = true - } - }, - modifier = modifier, - ) - if (showDialog) { - TrailerDialog( - onDismissRequest = { showDialog = false }, - trailers = trailers, - onClick = trailerOnClick, - ) - } -} - -@Composable -fun DeleteButton( - onConfirmDelete: () -> Unit, - title: String, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - var showDeleteDialog by remember { mutableStateOf(false) } - val focused by interactionSource.collectIsFocusedAsState() - val iconTint by - animateColorAsState( - targetValue = - if (focused) { - Color.Red.copy(alpha = .8f) - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.8f, - ) - }, - animationSpec = - tween( - easing = LinearEasing, - ), - ) - ExpandablePlayButton( - title = R.string.delete, - resume = Duration.ZERO, - icon = { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, - modifier = - Modifier - .padding(start = 2.dp) - .size(24.dp), - ) - }, - onClick = { showDeleteDialog = true }, - interactionSource = interactionSource, - modifier = modifier, - ) - if (showDeleteDialog) { - ConfirmDeleteDialog( - itemTitle = title, - onCancel = { showDeleteDialog = false }, - onConfirm = { - onConfirmDelete.invoke() - showDeleteDialog = false - }, - ) - } -} - @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { WholphinTheme(true) { ExpandablePlayButtons( - title = "Movie", resumePosition = 10.seconds, watched = false, favorite = false, @@ -489,69 +226,7 @@ private fun ExpandablePlayButtonsPreview() { favoriteOnClick = {}, moreOnClick = {}, buttonOnFocusChanged = {}, - trailers = listOf(), - trailerOnClick = {}, - canDelete = true, - onConfirmDelete = {}, - chooseVersionParams = null, modifier = Modifier, ) } } - -@PreviewTvSpec -@Composable -private fun ViewOptionsPreview() { - val source = remember { PreviewInteractionSource() } - WholphinTheme { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = {}, - interactionSource = source, - ) - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = painterResource(R.drawable.baseline_pause_24), - onClick = {}, - interactionSource = source, - ) - ExpandableFaButton( - title = R.string.play, - iconStringRes = R.string.fa_eye, - onClick = {}, - modifier = Modifier, - interactionSource = source, - ) - - Row { - ExpandableFaButton( - title = R.string.mark_unwatched, - iconStringRes = R.string.fa_eye, - onClick = {}, - modifier = Modifier, - ) - DeleteButton( - onConfirmDelete = {}, - title = "Movie", - ) - SortByButton( - sortOptions = listOf(), - current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - onSortChange = {}, - ) - } - DeleteButton( - onConfirmDelete = {}, - title = "Movie", - interactionSource = source, - ) - } - } -} 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 deleted file mode 100644 index 902ce814..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.Row -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -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.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.github.damontecres.wholphin.preferences.DisplayToggle -import com.github.damontecres.wholphin.services.SeerrRatingsService -import com.github.damontecres.wholphin.ui.dot -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 - -/** - * The line of details under a title: year, runtime, etc, then the ratings, then when it ends. - * - * Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null - * for surfaces without ratings. - */ -@Composable -fun QuickDetails( - details: QuickDetailsData?, - timeRemaining: Duration?, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, - endsAt: DateTime? = null, - ratings: SourceRatings? = null, -) { - val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles - Row(modifier = modifier) { - if (details != null) { - QuickDetailsText(details.basic, Modifier, textStyle) - if (DisplayToggle.OFFICIAL_RATING in enabled) { - QuickDetailsText(details.officialRating, Modifier, textStyle) - } - SourceRatingsRow(ratings, Modifier, textStyle) - } - if (timeRemaining != null) { - TimeRemaining(timeRemaining, textStyle = textStyle) - } else if (endsAt != null) { - EndsAt(endsAt, textStyle = textStyle) - } - } -} - -/** - * [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]). - */ -@Composable -fun QuickDetails( - item: BaseItem, - timeRemaining: Duration?, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, - endsAt: DateTime? = null, -) = QuickDetails( - details = item.ui.quickDetails, - timeRemaining = timeRemaining, - modifier = modifier, - textStyle = textStyle, - endsAt = endsAt, - ratings = rememberSourceRatings(item), -) - -@NonRestartableComposable -@Composable -fun QuickDetailsText( - str: AnnotatedString?, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, -) = str?.let { - Text( - text = str, - color = MaterialTheme.colorScheme.onSurface, - style = textStyle, - maxLines = 1, - modifier = modifier, - ) -} - -@Composable -fun TimeRemaining( - remaining: Duration, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, -) { - 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(endsAt, resources, context) { - val endTimeStr = formatTime(context, endsAt) - buildAnnotatedString { - dot() - append(resources.getString(R.string.ends_at, endTimeStr)) - } - } - Text( - text = remainingStr, - style = textStyle, - maxLines = 1, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index 460eda05..a1e8598b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -1,29 +1,89 @@ package com.github.damontecres.wholphin.ui.components +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.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +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.geometry.Offset +import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon -import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playOnClickSound +import com.github.damontecres.wholphin.ui.playSoundOnFocus +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import java.util.Locale + +enum class StarRatingPrecision { + FULL, + HALF, + QUARTER, + ; + + companion object { + fun fromFloat(value: Float): StarRatingPrecision = + if (value <= .25f) { + QUARTER + } else if (value <= .5f) { + HALF + } else { + FULL + } + } +} val FilledStarColor = Color(0xFFFFC700) val EmptyStarColor = Color(0xFF5D4A0A) -/** - * A minimum rating such as "7+" with a star, for the rating filter menus. - * - * This is not for showing a title's rating, see [SourceRatingsRow] for that. - */ +val ratingBarHeight: Dp = 32.dp + +@Composable +fun SimpleStarRating( + communityRating: Float?, + modifier: Modifier = Modifier, +) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + SimpleStarRating( + text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) }, + modifier = modifier, + ) +} + @Composable fun SimpleStarRating( text: String?, @@ -38,16 +98,175 @@ fun SimpleStarRating( if (text.isNotNullOrBlank()) { Text( text = text, - maxLines = 1, modifier = Modifier, ) - val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } Icon( imageVector = Icons.Filled.Star, tint = starColor, contentDescription = null, - modifier = Modifier.height(height), + modifier = Modifier, ) } } } + +/** + * Shows a rating out of 5 stars + */ +@Composable +fun StarRating( + rating100: Int, + onRatingChange: (Int) -> Unit, + precision: StarRatingPrecision, + enabled: Boolean, + playSoundOnFocus: Boolean, + modifier: Modifier = Modifier, + bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background, +) { + val context = LocalContext.current + var tempRating by remember(rating100) { mutableIntStateOf(rating100) } + val percentage = (if (enabled) tempRating else rating100) / 100f + val focusRequesters = remember { List(5) { FocusRequester() } } + Box( + modifier = + modifier + .clip(RoundedCornerShape(16.dp)) + .background(bgColor), + ) { + LazyRow( + modifier = + Modifier + .selectableGroup() + .padding(4.dp) + .drawWithCache { + onDrawWithContent { + drawContent() + if (percentage in 0f..<1f) { + drawRect( + color = bgColor, + topLeft = Offset(size.width * percentage, 0f), + blendMode = BlendMode.SrcAtop, + ) + } + } + }.focusGroup() + .focusProperties { + onEnter = { + val index = + if (rating100 <= 20) { + 0 + } else if (rating100 <= 40) { + 1 + } else if (rating100 <= 60) { + 2 + } else if (rating100 <= 80) { + 3 + } else { + 4 + } + focusRequesters[index].tryRequestFocus() + } + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + for (i in 1..5) { + item { + val isRated = (if (enabled) tempRating else rating100) >= (i * 20) + val icon = Icons.Filled.Star + var focused by remember { mutableStateOf(false) } + + val focusedColor = + if (focused) { + MaterialTheme.colorScheme.border + } else { + Color.Unspecified + } + Box( + modifier = + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(focusedColor), + ) { + Icon( + imageVector = icon, + tint = FilledStarColor, + contentDescription = null, + modifier = + if (enabled) { + Modifier + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) { + tempRating = i * 20 + } else { + tempRating = rating100 + } + }.playSoundOnFocus(playSoundOnFocus) + .focusRequester(focusRequesters[i - 1]) + .focusProperties { + left = + if (i == 1) focusRequesters.last() else FocusRequester.Default + right = + if (i == 5) focusRequesters.first() else FocusRequester.Default + }.selectable( + selected = isRated, + onClick = { + if (playSoundOnFocus) playOnClickSound(context) + val newRating100 = + when (precision) { + StarRatingPrecision.FULL -> + if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20 + + StarRatingPrecision.HALF -> { + if (rating100 > i * 20) { + i * 20 + } else if (rating100 == i * 20) { + i * 20 - 10 + } else if (rating100 == i * 20 - 10) { + (i - 1) * 20 + } else if (i == 1 && rating100 == 0) { + 20 + } else if (i == 1 && rating100 > 10) { + 10 + } else if (i == 1 && rating100 <= 10) { + 0 + } else { + (i) * 20 + } + } + + StarRatingPrecision.QUARTER -> { + // TODO + null + } + } + if (newRating100 != null) { + tempRating = newRating100 + onRatingChange(newRating100) + } + }, + ) + } else { + Modifier + }.fillMaxHeight() + .aspectRatio(1f), + ) + } + } + } + } + } +} + +@PreviewTvSpec +@Composable +private fun SimpleStarRatingPreview() { + WholphinTheme { + Column { + SimpleStarRating(7.5f, Modifier.height(32.dp)) + SimpleStarRating(7.5f, Modifier.height(20.dp)) + SimpleStarRating("", Modifier.height(32.dp)) + } + } +} 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 ca4ae7ff..3a561785 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 @@ -1,349 +1,85 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData 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.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.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 -import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.music.addToQueue -import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager -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.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -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.GetItemsRequest -import timber.log.Timber +import org.jellyfin.sdk.model.api.MediaType import java.util.UUID -@HiltViewModel(assistedFactory = RecommendedViewModel.Factory::class) -class RecommendedViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - private val userPreferencesService: UserPreferencesService, - private val favoriteWatchManager: FavoriteWatchManager, - private val musicService: MusicService, - private val backdropService: BackdropService, - private val mediaManagementService: MediaManagementService, - private val suggestionService: SuggestionService, - val serverReportService: ServerReportService, - @Assisted private val parentId: UUID, - @Assisted private val suggestionsType: BaseItemKind, - @Assisted private val recommendedRows: List<RecommendedRow<*>>, - @Assisted private val viewOptions: HomeRowViewOptions, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create( - parentId: UUID, - suggestionsType: BaseItemKind, - recommendedRows: List<RecommendedRow<*>>, - viewOptions: HomeRowViewOptions, - ): RecommendedViewModel - } +abstract class RecommendedViewModel( + val navigationManager: NavigationManager, + val favoriteWatchManager: FavoriteWatchManager, +) : ViewModel() { + abstract fun init() - private val _state = MutableStateFlow(RecommendedState()) - val state: StateFlow<RecommendedState> = _state + abstract val rows: MutableStateFlow<List<HomeRowLoadingState>> - fun init() { - viewModelScope.launchDefault { - val limit = - userPreferencesService.flow - .first() - .appPreferences.homePagePreferences.maxItemsPerRow - _state.update { - it.copy( - loading = LoadingState.Loading, - rows = - recommendedRows.map { HomeRowLoadingState.Loading(ResStringProvider(it.title)) } + - listOf(HomeRowLoadingState.Loading(ResStringProvider(R.string.suggestions))), - ) - } - val jobs = - recommendedRows.mapIndexed { index, row -> - val title = ResStringProvider(row.title) - viewModelScope.launchIO { - val result = - try { - val items = execute(row, limit) - HomeRowLoadingState.Success( - title, - items, - viewOptions, - showViewMore = items.size >= limit, - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error(title, null, ex) - } - _state.update { - it.copy( - rows = - it.rows.toMutableList().apply { - set(index, result) - }, - ) - } - } - } - fetchSuggestions() - jobs.forEachIndexed { index, job -> - job.join() - val row = state.value.rows[index] - if (row is HomeRowLoadingState.Success && row.items.isNotEmpty()) { - // Report loading success once the first non-empty row is ready - _state.update { it.copy(loading = LoadingState.Success) } - } - } - } - } + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - private suspend fun <T> execute( - row: RecommendedRow<T>, - limit: Int?, - ): List<BaseItem?> = - if (limit != null) { - val request = row.handler.prepare(row.request, 0, limit, false) - row.handler - .execute(api, request) - .toBaseItems(api, true) - } else { - ApiRequestPager( - api, - row.request, - row.handler, - viewModelScope, - useSeriesForPrimary = true, - ).init() - } - - private fun fetchSuggestions() { - viewModelScope.launch(WholphinDispatchers.IO) { - val limit = - userPreferencesService - .getCurrent() - .appPreferences.homePagePreferences.maxItemsPerRow - val title = ResStringProvider(R.string.suggestions) - try { - suggestionService - .getSuggestionsFlow(parentId, suggestionsType) - .collect { resource -> - val result = - when (resource) { - is SuggestionsResource.Loading -> { - HomeRowLoadingState.Loading(title) - } - - is SuggestionsResource.Success -> { - HomeRowLoadingState.Success( - title, - resource.items, - viewOptions, - showViewMore = resource.items.size >= limit, - ) - } - - is SuggestionsResource.Empty -> { - HomeRowLoadingState.Success( - title, - emptyList(), - viewOptions, - showViewMore = false, - ) - } - } - _state.update { - it.copy( - rows = - it.rows.toMutableList().apply { - set(lastIndex, result) - }, - ) - } - } - } catch (_: CancellationException) { - // no-op - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch suggestions") - _state.update { - it.copy( - rows = - it.rows.toMutableList().apply { - set(lastIndex, HomeRowLoadingState.Error(title, null, ex)) - }, - ) - } - } - } - } - - fun refreshItem( - position: RowColumn, - itemId: UUID, - ) { - viewModelScope.launchIO { - val row = state.value.rows.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) - } - } - } - - fun setWatched( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setWatched(itemId, watched) - refreshItem(position, itemId) - } - } - - fun setFavorite( - position: RowColumn, - itemId: UUID, - watched: Boolean, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemId, watched) - refreshItem(position, itemId) - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun deleteItem( - position: RowColumn, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - val row = state.value.rows.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) - } - } - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) - - fun onClickViewMore( - position: RowColumn, - row: HomeRowLoadingState.Success, - ) { - if (position.row in recommendedRows.indices) { - val recommendedRow = recommendedRows[position.row] as RecommendedRow<Any> - navigationManager.navigateTo( - Destination.ItemGrid( - title = row.title, - request = recommendedRow.request, - requestHandler = recommendedRow.handler, - initialPosition = row.items.size, - viewOptions = - ViewOptions( - aspectRatio = viewOptions.aspectRatio, - ), - ), - ) - } else { - // Suggestions - navigationManager.navigateTo( - Destination.ItemGrid( - title = row.title, - request = - GetItemsRequest( - ids = row.items.mapNotNull { it?.id }, - ), - requestHandler = GetItemsRequestHandler, - initialPosition = row.items.size, - viewOptions = - ViewOptions( - aspectRatio = viewOptions.aspectRatio, - ), - ), - ) + fun refreshItem( + position: RowColumn, + itemId: UUID, + ) { + viewModelScope.launchIO { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) } } } -data class RecommendedState( - val loading: LoadingState = LoadingState.Pending, - val rows: List<HomeRowLoadingState> = emptyList(), -) + fun setWatched( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setWatched(itemId, watched) + refreshItem(position, itemId) + } + } -data class RecommendedRow<T>( - val title: Int, - val handler: RequestHandler<T>, - val request: T, -) + fun setFavorite( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setFavorite(itemId, watched) + refreshItem(position, itemId) + } + } +} @Composable fun RecommendedContent( @@ -353,121 +89,68 @@ fun RecommendedContent( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), onFocusPosition: ((RowColumn) -> Unit)? = null, ) { - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } + val context = LocalContext.current + var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { viewModel.init() } - val state by viewModel.state.collectAsState() + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val rows by viewModel.rows.collectAsState() - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) LoadingState.Loading, LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - var position by rememberPosition() - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel.navigationManager::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - 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 - }, - ) - } + -> LoadingPage() + LoadingState.Success -> HomePageContent( - homeRows = state.rows, - position = position, + homeRows = rows, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, onLongClickItem = { position, item -> - 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, - ) - }, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - }, - onFocusPosition = { - position = it - val nonEmptyRowBefore = - state.rows - .subList(0, it.row) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition?.invoke( - RowColumn( - it.row - nonEmptyRowBefore, - it.column, - ), - ) + moreDialog.makePresent(RowColumnItem(position, item)) }, + onFocusPosition = onFocusPosition, showClock = preferences.appPreferences.interfacePreferences.showClock, - onUpdateBackdrop = viewModel::updateBackdrop, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - showViewMore = true, - onClickViewMore = viewModel::onClickViewMore, modifier = modifier, ) - } } - 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, + moreDialog.compose { (position, item) -> + DialogPopup( + showDialog = true, + title = item.title ?: "", + dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, watched -> + viewModel.setFavorite(position, itemId, watched) + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(it) + }, + ), + ), + onDismissRequest = { moreDialog.makeAbsent() }, + dismissOnClick = true, + waitToLoad = true, ) } showPlaylistDialog.compose { itemId -> @@ -484,8 +167,12 @@ fun RecommendedContent( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, - onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } + +private data class RowColumnItem( + val position: RowColumn, + val item: BaseItem, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index febe0199..06159073 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -1,93 +1,215 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.setValueOnMain +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.GetResumeItemsRequestHandler +import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +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.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import java.time.LocalDateTime +import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber import java.util.UUID -private fun getRecommendedRows(parentId: UUID) = - listOf( - RecommendedRow( - title = R.string.continue_watching, - handler = GetResumeItemsRequestHandler, - request = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - enableUserData = true, - enableTotalRecordCount = false, - ), - ), - RecommendedRow( - title = R.string.recently_released, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = - listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SORT_NAME, +@HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class) +class RecommendedMovieViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + @Assisted val parentId: UUID, + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedMovieViewModel + } + + override val rows = + MutableStateFlow<List<HomeRowLoadingState>>( + rowTitles + .map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }, + ) + + override fun init() { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + try { + val resumeItemsRequest = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + enableUserData = true, + ) + val resumeItems = + ApiRequestPager( + api, + resumeItemsRequest, + GetResumeItemsRequestHandler, + viewModelScope, + ).init() + if (resumeItems.isNotEmpty()) { + resumeItems.getBlocking(0) + } + + update( + 0, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - ), - enableTotalRecordCount = false, - maxPremiereDate = LocalDateTime.now(), - ), - ), - RecommendedRow( - title = R.string.recently_added, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), - RecommendedRow( - title = R.string.top_unwatched, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), - ) + ) + + if (resumeItems.isNotEmpty()) { + loading.setValueOnMain(LoadingState.Success) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching movie recommendations") + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + } + } + + val recentlyReleasedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val recentlyReleasedItems = + ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope) + + val recentlyAddedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val recentlyAddedItems = + ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope) + + val suggestionsRequest = + GetSuggestionsRequest( + userId = serverRepository.currentUser.value?.id, + type = listOf(BaseItemKind.MOVIE), + ) + val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) + + val unwatchedTopRatedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.MOVIE), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val unwatchedTopRatedItems = + ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + + val rows = + listOf( + R.string.recently_released to recentlyReleasedItems, + R.string.recently_added to recentlyAddedItems, + R.string.suggestions to suggestedItems, + R.string.top_unwatched to unwatchedTopRatedItems, + ) + rows + .mapIndexed { index, (title, pager) -> + viewModelScope.async(Dispatchers.IO + ExceptionHandler()) { + val title = context.getString(title) + val result = + try { + pager.init() + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + update(index + 1, result) + if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() && + (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) + ) { + loading.setValueOnMain(LoadingState.Success) + } + } + }.awaitAll() + if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { + loading.setValueOnMain(LoadingState.Success) + } + } + } + + private fun update( + position: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.toMutableList().apply { set(position, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.continue_watching, + R.string.recently_released, + R.string.recently_added, + R.string.suggestions, + R.string.top_unwatched, + ) + } + } /** * The "recommended" tab of a movie library @@ -98,16 +220,9 @@ fun RecommendedMovie( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedViewModel = - hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( - creationCallback = { - it.create( - parentId = parentId, - suggestionsType = BaseItemKind.MOVIE, - recommendedRows = getRecommendedRows(parentId), - viewOptions = HomeRowViewOptions(), - ) - }, + viewModel: RecommendedMovieViewModel = + hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>( + creationCallback = { it.create(parentId) }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt deleted file mode 100644 index 73e9af44..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import java.time.LocalDateTime -import java.util.UUID - -private fun getRecommendedRows(parentId: UUID) = - listOf( - RecommendedRow( - title = R.string.recently_released, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = - listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SORT_NAME, - ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - ), - enableTotalRecordCount = false, - maxPremiereDate = LocalDateTime.now(), - ), - ), - RecommendedRow( - title = R.string.recently_added, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), - RecommendedRow( - title = R.string.top_rated, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), - ) - -@Composable -fun RecommendedMusic( - preferences: UserPreferences, - parentId: UUID, - onFocusPosition: (RowColumn) -> Unit, - modifier: Modifier = Modifier, - viewModel: RecommendedViewModel = - hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( - creationCallback = { - it.create( - parentId = parentId, - suggestionsType = BaseItemKind.MUSIC_ALBUM, - recommendedRows = getRecommendedRows(parentId), - viewOptions = - HomeRowViewOptions( - aspectRatio = AspectRatio.SQUARE, - heightDp = Cards.HEIGHT_EPISODE, - showTitles = true, - ), - ) - }, - ), -) { - RecommendedContent( - preferences = preferences, - viewModel = viewModel, - onFocusPosition = onFocusPosition, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 78caf524..e1a18517 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -1,113 +1,245 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.setValueOnMain +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.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler +import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +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.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import java.time.LocalDateTime +import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest +import timber.log.Timber import java.util.UUID -private fun getRecommendedRows( - parentId: UUID, - enableRewatching: Boolean, -) = listOf( - RecommendedRow( - title = R.string.continue_watching, - handler = GetResumeItemsRequestHandler, - request = - GetResumeItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - enableUserData = true, - enableTotalRecordCount = false, - ), - ), - RecommendedRow( - title = R.string.next_up, - handler = GetNextUpRequestHandler, - request = - GetNextUpRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = parentId, - enableResumable = false, - enableUserData = true, - enableRewatching = enableRewatching, - ), - ), - RecommendedRow( - title = R.string.recently_released, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = +@HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class) +class RecommendedTvShowViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + @Assisted val parentId: UUID, + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { + @AssistedFactory + interface Factory { + fun create(parentId: UUID): RecommendedTvShowViewModel + } + + override val rows = + MutableStateFlow<List<HomeRowLoadingState>>( + rowTitles + .map { + HomeRowLoadingState.Pending( + context.getString(it), + ) + }, + ) + + override fun init() { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + try { + val resumeItemsRequest = + GetResumeItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + enableUserData = true, + ) + val resumeItems = + ApiRequestPager( + api, + resumeItemsRequest, + GetResumeItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (resumeItems.isNotEmpty()) { + resumeItems.getBlocking(0) + } + + val nextUpRequest = + GetNextUpRequest( + parentId = parentId, + fields = SlimItemFields, + enableUserData = true, + ) + val nextUpItems = + ApiRequestPager( + api, + nextUpRequest, + GetNextUpRequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (nextUpItems.isNotEmpty()) { + nextUpItems.getBlocking(0) + } + + update( + 0, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, + ), + ) + update( + 1, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + nextUpItems, + ), + ) + + if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { + loading.setValueOnMain(LoadingState.Success) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching tv recommendations") + withContext(Dispatchers.Main) { + loading.value = LoadingState.Error(ex) + } + } + + val recentlyReleasedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val recentlyReleasedItems = + ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + + val recentlyAddedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.EPISODE), + recursive = true, + enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val recentlyAddedItems = + ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + + val suggestionsRequest = + GetSuggestionsRequest( + userId = serverRepository.currentUser.value?.id, + type = listOf(BaseItemKind.SERIES), + ) + val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) + + val unwatchedTopRatedRequest = + GetItemsRequest( + parentId = parentId, + fields = SlimItemFields, + includeItemTypes = listOf(BaseItemKind.SERIES), + recursive = true, + enableUserData = true, + isPlayed = false, + sortBy = listOf(ItemSortBy.COMMUNITY_RATING), + sortOrder = listOf(SortOrder.DESCENDING), + ) + val unwatchedTopRatedItems = + ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) + + val rows = listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SERIES_SORT_NAME, - ItemSortBy.AIRED_EPISODE_ORDER, - ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - SortOrder.DESCENDING, - ), - enableTotalRecordCount = false, - maxPremiereDate = LocalDateTime.now(), - isUnaired = false, - ), - ), - RecommendedRow( - title = R.string.recently_added, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.EPISODE), - recursive = true, - enableUserData = true, - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), - RecommendedRow( - title = R.string.top_unwatched, - handler = GetItemsRequestHandler, - request = - GetItemsRequest( - parentId = parentId, - fields = SlimItemFields, - includeItemTypes = listOf(BaseItemKind.SERIES), - recursive = true, - enableUserData = true, - isPlayed = false, - sortBy = listOf(ItemSortBy.COMMUNITY_RATING), - sortOrder = listOf(SortOrder.DESCENDING), - enableTotalRecordCount = false, - ), - ), -) + R.string.recently_released to recentlyReleasedItems, + R.string.recently_added to recentlyAddedItems, + R.string.suggestions to suggestedItems, + R.string.top_unwatched to unwatchedTopRatedItems, + ) + + rows + .mapIndexed { index, (title, pager) -> + viewModelScope.async(Dispatchers.IO + ExceptionHandler()) { + val title = context.getString(title) + val result = + try { + pager.init() + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", title) + HomeRowLoadingState.Error(title, null, ex) + } + update(index + 2, result) + if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() && + (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) + ) { + loading.setValueOnMain(LoadingState.Success) + } + } + }.awaitAll() + if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { + loading.setValueOnMain(LoadingState.Success) + } + } + } + + private fun update( + position: Int, + row: HomeRowLoadingState, + ) { + rows.update { current -> + current.toMutableList().apply { set(position, row) } + } + } + + companion object { + private val rowTitles = + listOf( + R.string.continue_watching, + R.string.next_up, + R.string.recently_released, + R.string.recently_added, + R.string.suggestions, + R.string.top_unwatched, + ) + } + } /** * The "recommended" tab of a TV show library @@ -118,20 +250,9 @@ fun RecommendedTvShow( parentId: UUID, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, - viewModel: RecommendedViewModel = - hiltViewModel<RecommendedViewModel, RecommendedViewModel.Factory>( - creationCallback = { - it.create( - parentId = parentId, - suggestionsType = BaseItemKind.SERIES, - recommendedRows = - getRecommendedRows( - parentId, - preferences.appPreferences.homePagePreferences.enableRewatchingNextUp, - ), - viewOptions = HomeRowViewOptions(), - ) - }, + viewModel: RecommendedTvShowViewModel = + hiltViewModel<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>( + creationCallback = { it.create(parentId) }, ), ) { RecommendedContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt deleted file mode 100644 index d06fd652..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import androidx.tv.material3.LocalContentColor - -/** - * Shows a dot indicator - * - * Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem] - */ -@Composable -fun BoxScope.SelectedLeadingContent( - selected: Boolean, - modifier: Modifier = Modifier, -) { - if (selected) { - Box( - modifier = - modifier - .padding(horizontal = 4.dp) - .clip(CircleShape) - .align(Alignment.Center) - .background(LocalContentColor.current) - .size(8.dp), - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt deleted file mode 100644 index 0ea3d74f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.sp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.jellyfin.sdk.model.api.BaseItemDto - -@Composable -fun SeriesName( - seriesName: String?, - modifier: Modifier = Modifier, -) { - Text( - text = seriesName ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} - -@Composable -fun EpisodeName( - episodeName: String?, - modifier: Modifier = Modifier, -) { - Text( - text = episodeName ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - fontSize = 20.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} - -@Composable -fun EpisodeName( - episode: BaseItemDto?, - modifier: Modifier = Modifier, -) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier) 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 9021e213..11f6a4ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -40,7 +40,7 @@ fun SliderBar( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interval: Int = 1, - colors: SliderColors = SliderColors.default(), + color: Color = MaterialTheme.colorScheme.border, ) { val isFocused by interactionSource.collectIsFocusedAsState() val animatedIndicatorHeight by animateDpAsState( @@ -49,6 +49,9 @@ fun SliderBar( var currentValue by remember(value) { mutableLongStateOf(value) } val percent = (currentValue - min).toFloat() / (max - min) + val activeColor = SliderActiveColor(isFocused) + val inactiveColor = SliderInactiveColor(isFocused) + val handleSeekEventModifier = Modifier.handleDPadKeyEvents( triggerOnAction = KeyEvent.ACTION_DOWN, @@ -88,14 +91,14 @@ fun SliderBar( onDraw = { val yOffset = size.height.div(2) drawLine( - color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused, + color = inactiveColor, start = Offset(x = 0f, y = yOffset), end = Offset(x = size.width, y = yOffset), strokeWidth = size.height, cap = StrokeCap.Round, ) drawLine( - color = if (isFocused) colors.activeFocused else colors.activeUnfocused, + color = activeColor, start = Offset(x = 0f, y = yOffset), end = Offset( @@ -116,29 +119,8 @@ fun SliderBar( } } -data class SliderColors( - val activeFocused: Color, - val activeUnfocused: Color, - val inactiveFocused: Color, - val inactiveUnfocused: Color, -) { - companion object { - @Composable - fun default() = - SliderColors( - activeFocused = sliderActiveColor(true), - activeUnfocused = sliderActiveColor(false), - inactiveFocused = sliderInactiveColor(true), - inactiveUnfocused = sliderInactiveColor(false), - ) - } -} - -/** - * Determines the active color for the slider. This is the "filled" left side of the slider - */ @Composable -fun sliderActiveColor(focused: Boolean): Color { +fun SliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, @@ -146,62 +128,47 @@ fun sliderActiveColor(focused: Boolean): Color { AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, - AppThemeColors.RED, - AppThemeColors.BROWN, - -> { - MaterialTheme.colorScheme.border - } + -> MaterialTheme.colorScheme.border - AppThemeColors.BOLD_BLUE -> { + AppThemeColors.BOLD_BLUE -> if (focused) { MaterialTheme.colorScheme.border } else { MaterialTheme.colorScheme.border } - } - AppThemeColors.OLED_BLACK -> { + AppThemeColors.OLED_BLACK -> if (focused) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.border } - } } } -/** - * Determines the inactive color for the slider. This is background of "unfilled" right side of the slider. - */ @Composable -fun sliderInactiveColor(focused: Boolean): Color { +fun SliderInactiveColor(focused: Boolean): Color { val theme = LocalTheme.current return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - -> { + -> MaterialTheme.colorScheme.border .copy(alpha = .25f) .compositeOver(MaterialTheme.colorScheme.surfaceVariant) .copy(alpha = .66f) - } AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, - AppThemeColors.RED, - AppThemeColors.BROWN, - -> { - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f) - } + -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .66f) - AppThemeColors.OLED_BLACK -> { + AppThemeColors.OLED_BLACK -> if (focused) { MaterialTheme.colorScheme.tertiaryContainer } else { MaterialTheme.colorScheme.primaryContainer } - } } } 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 3e2555db..d63842c1 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 @@ -8,14 +8,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle +import androidx.tv.material3.Button 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 @@ -36,18 +36,35 @@ fun SortByButton( modifier: Modifier = Modifier, ) { val currentSort = current.sort + val name = stringResource(getStringRes(currentSort)) val currentDirection = current.direction var sortByDropDown by remember { mutableStateOf(false) } + val context = LocalContext.current Box(modifier = modifier) { - TextButton( + Button( onClick = { sortByDropDown = true }, onLongClick = { onSortChange.invoke(current.flip()) }, ) { Text( - text = current.toAnnotatedString(), + text = + buildAnnotatedString { + withStyle(SpanStyle(fontFamily = FontAwesome)) { + append( + stringResource( + if (currentDirection == SortOrder.ASCENDING) { + R.string.fa_caret_up + } else { + R.string.fa_caret_down + }, + ), + ) + } + append(" ") + append(name) + }, ) } @@ -99,112 +116,3 @@ fun SortByButton( } } } - -@Composable -fun SortAndDirection.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(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/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt deleted file mode 100644 index 19567883..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -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.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -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.RatingBadge -import com.github.damontecres.wholphin.data.model.RatingEntry -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.github.damontecres.wholphin.preferences.DisplayToggle -import com.github.damontecres.wholphin.services.SeerrRatingsService -import com.github.damontecres.wholphin.ui.dot -import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization - -/** - * Provides the [SeerrRatingsService] to [rememberSourceRatings] - */ -val LocalSeerrRatingsService = - staticCompositionLocalOf<SeerrRatingsService> { throw IllegalStateException("LocalSeerrRatingsService not set") } - -/** - * The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every - * screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]). - * - * Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and - * never the previous item's ratings. - */ -@Composable -fun rememberSourceRatings(item: BaseItem?): SourceRatings? { - val service = LocalSeerrRatingsService.current - val key = item?.id - var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) } - LaunchedEffect(key) { - // Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running - service.forItem(item).collect { ratings = it } - } - return ratings -} - -@DrawableRes -fun RatingBadge.drawable(): Int = - when (this) { - RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh - RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten - RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright - RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled - RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh - RatingBadge.IMDB -> R.drawable.ic_rating_imdb - RatingBadge.TMDB -> R.drawable.ic_rating_tmdb - } - -/** - * The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA. - */ -@Composable -private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent> = - remember(textStyle) { - val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) - val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) - RatingBadge.entries.associate { badge -> - val muted = badge == RatingBadge.RT_UNKNOWN - val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square - badge.name to - InlineTextContent(placeholder) { - Image( - painter = painterResource(badge.drawable()), - contentDescription = null, - contentScale = ContentScale.Fit, - alpha = if (muted) 0.55f else 1f, - colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - -/** - * The text for the ratings: each as a badge and its number, separated by dots - * - * @param leadingDot whether a dot is put in front of the first rating because something precedes it - * @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score - */ -fun ratingsText( - entries: List<RatingEntry>, - leadingDot: Boolean, - episodeLabel: String, -): AnnotatedString = - buildAnnotatedString { - entries.forEachIndexed { index, entry -> - if (index > 0 || leadingDot) dot() - appendInlineContent(entry.badge.name) - append(" ") - if (entry.isEpisode) append("$episodeLabel ") - append(entry.text) - } - } - -/** - * The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB. - * - * Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if - * there is nothing to show. - */ -@Composable -fun SourceRatingsRow( - ratings: SourceRatings?, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, - leadingDot: Boolean = true, -) { - if (ratings == null) return - val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles - val community = DisplayToggle.COMMUNITY_RATING in enabled - val critic = DisplayToggle.CRITIC_RATING in enabled - val episodeLabel = stringResource(R.string.rating_episode_label) - val text = - remember(ratings, community, critic, leadingDot, episodeLabel) { - ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) } - } ?: return - val badges = rememberBadges(textStyle) - Text( - text = text, - color = MaterialTheme.colorScheme.onSurface, - style = textStyle, - inlineContent = badges, - maxLines = 1, - modifier = modifier, - ) -} 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 deleted file mode 100644 index b69e1126..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.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.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.times -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.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 -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.StudioCard -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.CardGridItem -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.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -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.ImageType -import org.jellyfin.sdk.model.api.request.GetStudiosRequest -import timber.log.Timber -import java.util.UUID - -@HiltViewModel(assistedFactory = StudioViewModel.Factory::class) -class StudioViewModel - @AssistedInject - constructor( - private val api: ApiClient, - private val imageUrlService: ImageUrlService, - private val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - @Assisted private val itemId: UUID, - @Assisted private val includeItemTypes: List<BaseItemKind>?, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create( - itemId: UUID, - includeItemTypes: List<BaseItemKind>?, - ): StudioViewModel - } - - private val _state = MutableStateFlow(StudioGridState()) - val state: StateFlow<StudioGridState> = _state - - fun init(cardWidthPx: Int) { - _state.update { it.copy(item = DataLoadingState.Loading) } - viewModelScope.launchIO { - try { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - val request = - GetStudiosRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val studios = - GetStudiosRequestHandler - .execute(api, request) - .content.items - .map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - fillWidth = cardWidthPx, - tag = it.imageTags?.get(ImageType.THUMB), - ) - Studio( - id = it.id, - name = it.name ?: "", - imageUrl = imageUrl, - sortName = it.alphabetSortName, - ) - } - _state.update { - it.copy( - item = DataLoadingState.Success(item), - studios = studios, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching studios") - _state.update { it.copy(item = DataLoadingState.Error(ex)) } - } - } - } - - suspend fun positionOfLetter(letter: Char): Int = - withContext(WholphinDispatchers.IO) { - GetStudiosRequestHandler.countMatching( - api = api, - request = - GetStudiosRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - includeItemTypes = includeItemTypes, - ), - ) - } - } - -@Stable -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 -} - -data class StudioGridState( - val item: DataLoadingState<BaseItem> = DataLoadingState.Pending, - val studios: List<Studio> = emptyList(), -) - -@Composable -fun StudioCardGrid( - itemId: UUID, - includeItemTypes: List<BaseItemKind>?, - modifier: Modifier = Modifier, - initialPosition: Int = 0, - viewModel: StudioViewModel = - hiltViewModel<StudioViewModel, StudioViewModel.Factory>( - creationCallback = { it.create(itemId, includeItemTypes) }, - ), -) { - val columns = 4 - val spacing = 16.dp - val density = LocalDensity.current - val configuration = LocalConfiguration.current - val cardWidthPx = - 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 - // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required - (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) - .div(columns) - .roundToPx() - } - } - OneTimeLaunchedEffect { - viewModel.init(cardWidthPx) - } - val state by viewModel.state.collectAsState() - - val gridFocusRequester = remember { FocusRequester() } - when (val st = state.item) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(modifier.focusable()) - } - - is DataLoadingState.Error -> { - ErrorMessage(st, modifier.focusable()) - } - - is DataLoadingState.Success<BaseItem> -> { - Box(modifier = modifier) { - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - CardGrid( - pager = state.studios, - onClickItem = { _, studio -> - viewModel.navigationManager.navigateTo( - createStudioDestination( - studioId = studio.id, - name = studio.name, - parentId = itemId, - parentName = st.data.title, - includeItemTypes = includeItemTypes, - ), - ) - }, - onLongClickItem = { _, _ -> }, - onClickPlay = { _, _ -> }, - letterPosition = { viewModel.positionOfLetter(it) }, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, - showLetterButtons = true, - modifier = Modifier.fillMaxSize(), - initialPosition = initialPosition, - positionCallback = { columns, position -> - }, - columns = columns, - spacing = spacing, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - StudioCard( - studio = item, - onClick = onClick, - onLongClick = onLongClick, - modifier = mod, - ) - }, - ) - } - } - } -} 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 f2f60ad8..dafb5ed9 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 @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -14,20 +13,16 @@ 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState 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 @@ -43,144 +38,44 @@ 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<TabDetails>, + tabs: List<String>, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() - LaunchedEffect(selectedTabIndex) { - if (selectedTabIndex in tabs.indices) { - state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) - } + LaunchedEffect(Unit) { + state.animateScrollToItem(selectedTabIndex) } + val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } - - val currentSelectedTabIndex by rememberUpdatedState(selectedTabIndex) -// val currentFocusRequesters by rememberUpdatedState(focusRequesters) - val currentOnClick by rememberUpdatedState(onClick) - LazyRow( state = state, modifier = modifier .onFocusChanged { rowHasFocus = it.hasFocus - }.focusGroup() - .focusProperties { + }.focusProperties { onEnter = { - // If entering from left or right, use last or first tab - // Otherwise use the selected tab - val index = currentSelectedTabIndex -// val requesters = currentFocusRequesters - Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$index") - val tab = - if (requestedFocusDirection == FocusDirection.Left) { - tabs.lastOrNull() - } else if (requestedFocusDirection == FocusDirection.Right) { - tabs.firstOrNull() - } else { - tabs.getOrNull(index) - } - (tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus() + focusRequesters.getOrNull(selectedTabIndex)?.tryRequestFocus() } }, ) { - itemsIndexed(tabs) { index, tab -> + itemsIndexed(tabs) { index, tabTitle -> val interactionSource = remember { MutableInteractionSource() } - val onTabClick = - remember(index) { - { - currentOnClick(index) - } - } Tab( - title = tab.title.getString(), + title = tabTitle, selected = index == selectedTabIndex, rowActive = rowHasFocus, interactionSource = interactionSource, - onClick = onTabClick, - 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() - } + onClick = { + onClick.invoke(index) }, - ) { - 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), - ) - } + modifier = Modifier.focusRequester(focusRequesters[index]), + ) } } } @@ -270,11 +165,8 @@ private fun TabRowPreview() { WholphinTheme { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TabRow( - selectedTabIndex = -1, - tabs = - remember { - listOf("Tab 1", "Tab 2", "Tab 3").map { TabDetails(StringStringProvider(it)) } - }, + selectedTabIndex = 1, + tabs = listOf("Tab 1", "Tab 2", "Tab 3"), 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 deleted file mode 100644 index 0749c068..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt +++ /dev/null @@ -1,241 +0,0 @@ -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/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt deleted file mode 100644 index f70a4f6a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.util.LocalClock - -/** - * Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box] - */ -@Composable -fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { - val timeString by LocalClock.current.timeString - Text( - text = timeString, - fontSize = 18.sp, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge, - modifier = - modifier - .align(Alignment.TopEnd) - .padding(vertical = 16.dp, horizontal = 24.dp), - ) -} 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 deleted file mode 100644 index 4268f2d9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.widthIn -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.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -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.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.logCoilError -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType - -@Composable -fun TitleOrLogo( - title: String?, - logoImageUrl: String?, - showLogo: Boolean, - modifier: Modifier = Modifier, -) { - var imageError by remember { mutableStateOf(false) } - Box( - modifier = modifier.heightIn(max = HeaderUtils.logoHeight), - ) { - if (showLogo && logoImageUrl != null && !imageError) { - AsyncImage( - model = logoImageUrl, - contentDescription = title, - contentScale = ContentScale.Fit, - onError = { - logCoilError(logoImageUrl, it.result) - imageError = true - }, - modifier = - Modifier - .height(HeaderUtils.logoHeight) - .widthIn(max = 320.dp), - ) - } else { - Title(title, Modifier) - } - } -} - -@Composable -private fun Title( - title: String?, - modifier: Modifier = Modifier, -) { - Text( - text = title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} - -@Composable -fun TitleOrLogo( - item: BaseItem?, - showLogo: Boolean, - modifier: Modifier = Modifier, -) { - val logoImageUrl = rememberLogoUrl(item) - TitleOrLogo( - title = item?.title, - logoImageUrl = logoImageUrl, - showLogo = showLogo, - modifier = modifier, - ) -} - -@Composable -fun rememberLogoUrl(item: BaseItem?): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item?.id) { - if ((item?.type == BaseItemKind.EPISODE || item?.type == BaseItemKind.SEASON) && - item.data.seriesId != null && - item.data.parentLogoImageTag != null - ) { - imageUrlService.getItemImageUrl( - itemId = item.data.seriesId!!, - imageType = ImageType.LOGO, - tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId }, - ) - } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { - imageUrlService.getItemImageUrl(item, ImageType.LOGO) - } else { - null - } - } -} - -@Composable -fun SimpleTitleOrLogo( - item: BaseItem?, - showLogo: Boolean, - modifier: Modifier = Modifier, -) { - val logoImageUrl = rememberLogoUrl(item) - var imageError by remember { mutableStateOf(false) } - Box( - contentAlignment = Alignment.Center, - modifier = modifier, - ) { - if (showLogo && logoImageUrl != null && !imageError) { - AsyncImage( - model = logoImageUrl, - contentDescription = item?.title, - contentScale = ContentScale.Fit, - onError = { - logCoilError(logoImageUrl, it.result) - imageError = true - }, - modifier = Modifier.fillMaxSize(), - ) - } else { - Title(item?.title, Modifier) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt deleted file mode 100644 index 0b17491b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.LocalTrailer -import com.github.damontecres.wholphin.data.model.RemoteTrailer -import com.github.damontecres.wholphin.data.model.Trailer - -@Composable -fun TrailerDialog( - onDismissRequest: () -> Unit, - trailers: List<Trailer>, - onClick: (Trailer) -> Unit, -) { - val trailersStr = stringResource(R.string.play_trailer) - val localStr = stringResource(R.string.local) - val externalStr = stringResource(R.string.external_track) - val params = - remember(trailers) { - DialogParams( - fromLongClick = false, - title = trailersStr, - items = - trailers.map { trailer -> - DialogItem( - headlineContent = { - Text(trailer.name) - }, - supportingContent = { - val subtitle = - when (trailer) { - is LocalTrailer -> localStr - is RemoteTrailer -> trailer.subtitle ?: externalStr - } - Text( - text = subtitle, - ) - }, - onClick = { onClick.invoke(trailer) }, - ) - }, - ) - } - DialogPopup( - params = params, - onDismissRequest = onDismissRequest, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 94842ef5..a84d3458 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -1,23 +1,18 @@ package com.github.damontecres.wholphin.ui.components +import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.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.platform.LocalResources +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -28,52 +23,48 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.chooseSource +import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.AppThemeColors +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace -import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec -import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec -import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange -import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString import com.github.damontecres.wholphin.util.languageName -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.api.MediaStream - -@Composable -@NonRestartableComposable -fun VideoStreamDetails( - chosenStreams: ChosenStreams?, - numberOfVersions: Int, - modifier: Modifier = Modifier, -) = VideoStreamDetails( - chosenStreams?.source, - chosenStreams?.videoStream, - chosenStreams?.audioStream?.stream, - chosenStreams?.subtitleStream?.stream, - numberOfVersions, - modifier, -) +import com.github.damontecres.wholphin.util.profile.Codec +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType @Composable fun VideoStreamDetails( - source: MediaSourceInfo?, - videoStream: MediaStream?, - audioStream: MediaStream?, - subtitleStream: MediaStream?, - numberOfVersions: Int = 0, + preferences: UserPreferences, + dto: BaseItemDto, + itemPlayback: ItemPlayback?, modifier: Modifier = Modifier, ) { - val resources = LocalResources.current + val context = LocalContext.current Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { + val source = remember(dto, itemPlayback) { chooseSource(dto, itemPlayback) } + + val videoStream = + remember(dto, itemPlayback) { + chooseStream( + dto, + itemPlayback, + MediaStreamType.VIDEO, + preferences, + ) + } val video = remember(videoStream) { videoStream @@ -90,38 +81,36 @@ fun VideoStreamDetails( } else { null } - val range = - formatVideoRange( - resources, - it.videoRange, - it.videoRangeType, - it.videoDoViTitle, - ) - resName.concatWithSpace(range) - } + val range = formatVideoRange(context, it.videoRange, it.videoRangeType) + listOfNotNull( + resName.concatWithSpace(range), + it.codec?.uppercase(), + ) + }.orEmpty() } - video?.let { - StreamLabel( - text = it, - count = numberOfVersions, - ) - } - videoStream?.codec?.uppercase()?.let { + video.forEach { StreamLabel(it) } + val audioStream = + remember(dto, itemPlayback) { + chooseStream( + dto, + itemPlayback, + MediaStreamType.AUDIO, + preferences, + ) + } val audioCount = remember(source) { source?.audioStreamCount ?: 0 } val audio = - remember(audioCount, audioStream) { - if (audioCount == 0 || audioStream == null) { - resources.getString(R.string.none) - } else { - listOfNotNull( - languageName(audioStream.language), - formatAudioCodec(resources, audioStream.codec, audioStream.profile), - audioStream.channelLayout, - ).joinToString(" ") - } + if (audioCount == 0 || audioStream == null) { + stringResource(R.string.none) + } else { + listOfNotNull( + languageName(audioStream.language), + formatAudioCodec(context, audioStream.codec, audioStream.profile), + audioStream.channelLayout, + ).joinToString(" ") } StreamLabel( text = audio, @@ -130,26 +119,30 @@ fun VideoStreamDetails( modifier = Modifier.widthIn(max = 200.dp), ) + val subtitleStream = + remember(dto, itemPlayback) { + chooseStream( + dto, + itemPlayback, + MediaStreamType.SUBTITLE, + preferences, + ) + } val subtitleCount = remember(source) { (source?.embeddedSubtitleCount ?: 0) + (source?.externalSubtitlesCount ?: 0) } - var disabled by remember { mutableStateOf(false) } val subtitle = - remember(subtitleCount, subtitleStream) { - if (subtitleCount > 0 && subtitleStream == null) { - disabled = true - resources.getString(R.string.none) - } else if (subtitleCount == 0 || subtitleStream == null) { - null - } else { - disabled = false - listOfNotNull( - languageName(subtitleStream.language), - "SDH".takeIf { subtitleStream.isHearingImpaired }, - formatSubtitleCodec(subtitleStream.codec), - ).joinToString(" ") - } + if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { + stringResource(R.string.disabled) + } else if (subtitleCount == 0 || subtitleStream == null) { + null + } else { + listOfNotNull( + languageName(subtitleStream.language), + "SDH".takeIf { subtitleStream.isHearingImpaired }, + formatSubtitleCodec(subtitleStream.codec), + ).joinToString(" ") } subtitle?.let { StreamLabel( @@ -157,19 +150,46 @@ fun VideoStreamDetails( count = subtitleCount, icon = R.string.fa_closed_captioning, modifier = Modifier.widthIn(max = 160.dp), - disabled = disabled, ) } } } +fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" + +// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 +fun resolutionString( + width: Int, + height: Int, + interlaced: Boolean, +): String = + if (height > width) { + // Vertical video + resolutionString(height, width, interlaced) + } else { + when { + width <= 256 && height <= 144 -> "144" + interlaced(interlaced) + width <= 426 && height <= 240 -> "240" + interlaced(interlaced) + width <= 640 && height <= 360 -> "360" + interlaced(interlaced) + width <= 682 && height <= 384 -> "384" + interlaced(interlaced) + width <= 720 && height <= 404 -> "404" + interlaced(interlaced) + width <= 854 && height <= 480 -> "480" + interlaced(interlaced) + width <= 960 && height <= 544 -> "540" + interlaced(interlaced) + width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) + width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) + width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) + width <= 4096 && height <= 3072 -> "4K" + width <= 8192 && height <= 6144 -> "8K" + else -> height.toString() + interlaced(interlaced) + } + } + @Composable fun StreamLabel( text: String, modifier: Modifier = Modifier, @StringRes icon: Int? = null, count: Int = 0, - disabled: Boolean = false, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -196,21 +216,19 @@ fun StreamLabel( modifier = Modifier, ) } - val string = - remember(text, disabled, count) { - val countToUse = if (disabled) count else count - 1 - if (countToUse > 0) { - "$text (+$countToUse)" - } else { - text - } - } Text( - text = string, + text = text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + if (count > 1) { + Text( + text = "(+${count - 1})", + maxLines = 1, + modifier = Modifier, + ) + } } } } @@ -219,38 +237,85 @@ fun StreamLabel( @Composable private fun StreamLabelPreview() { WholphinTheme(appThemeColors = AppThemeColors.PURPLE) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .padding(8.dp), - ) { - StreamLabel("1080p") - StreamLabel("HDR") - StreamLabel("H264") - StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) - - StreamLabel("PGS", count = 1) - StreamLabel("PGS", count = 1, disabled = true) - StreamLabel("PGS", count = 2) - } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .background(MaterialTheme.colorScheme.background) - .padding(8.dp) - .fillMaxWidth(.7f), - ) { - StreamLabel("1080p") - StreamLabel("HDR") - StreamLabel("English Dolby Atmos 5.1", icon = R.string.fa_volume_high, count = 2) - StreamLabel("English SDH SRT", icon = R.string.fa_closed_captioning, count = 35) - } + StreamLabel("1080p") + StreamLabel("HDR") + StreamLabel("H264") + StreamLabel("AC3 5.1", icon = R.string.fa_volume_high, count = 2) } } } + +fun formatVideoRange( + context: Context, + videoRange: VideoRange?, + type: VideoRangeType?, +): String? = + when (videoRange) { + VideoRange.UNKNOWN, + VideoRange.SDR, null, + -> null + + VideoRange.HDR -> + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null + + VideoRangeType.HDR10 -> "HDR10" + VideoRangeType.HDR10_PLUS -> "HDR10+" + VideoRangeType.HLG -> "HLG" + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } + } + +fun formatAudioCodec( + context: Context, + codec: String?, + profile: String?, +): String? = + when { + profile?.contains("Dolby Atmos", true) == true -> context.getString(R.string.dolby_atmos) + profile?.contains("DTS:X", true) == true -> "DTS:X" + profile?.contains("DTS:HD", true) == true -> "DTS:HD" + else -> + when (codec?.lowercase()) { + Codec.Audio.TRUEHD -> "TrueHD" + Codec.Audio.OGG, + Codec.Audio.OPUS, + Codec.Audio.VORBIS, + -> codec.replaceFirstChar { it.uppercase() } + + null -> null + else -> codec.uppercase() + } + } + +fun formatSubtitleCodec(codec: String?): String? = + when (codec?.lowercase()) { + Codec.Subtitle.DVBSUB -> "DVB" + Codec.Subtitle.DVDSUB -> "DVD" + Codec.Subtitle.PGSSUB -> "PGS" + Codec.Subtitle.SUBRIP -> "SRT" + null -> null + else -> codec.uppercase() + } + +fun String?.concatWithSpace(str: String?): String? = + when { + this != null && str != null -> "$this $str" + this == null -> str + else -> this + } 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 deleted file mode 100644 index df923a55..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ /dev/null @@ -1,398 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.view.Gravity -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.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 -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppChoicePreference -import com.github.damontecres.wholphin.preferences.AppClickablePreference -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppSliderPreference -import com.github.damontecres.wholphin.preferences.AppSwitchPreference -import com.github.damontecres.wholphin.preferences.PrefContentScale -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.serialization.Serializable -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType - -/** - * A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions]. - * - * It displays the [AppPreference] objects from [ViewOptions.GRID_OPTIONS] - */ -@Composable -fun ViewOptionsDialog( - viewOptions: ViewOptions, - onDismissRequest: () -> Unit, - onViewOptionsChange: (ViewOptions) -> Unit, - defaultViewOptions: ViewOptions = ViewOptions(), - showArtistSource: Boolean = false, -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val columnState = rememberLazyListState() - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.END) - window.setDimAmount(0f) - } - val options = - remember(viewOptions.type, showArtistSource) { - when (viewOptions.type) { - ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource) - ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource) - ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource) - } - } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .width(256.dp) - .heightIn(max = 380.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - shape = RoundedCornerShape(8.dp), - ), - ) { - 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) - } - }, - ) - } - } - } - } -} - -/** Which Jellyfin artists list backs the Music > Artists tab */ -enum class ArtistSource { - ARTIST, - ALBUM_ARTIST, -} - -/** - * Stores the customizable view changes from the user - * - * This is used to determine how the UI looks such as card size and shape - */ -@Serializable -data class ViewOptions( - val columns: Int = 6, - val spacing: Int = 16, - val contentScale: PrefContentScale = PrefContentScale.FIT, - val aspectRatio: AspectRatio = AspectRatio.TALL, - val showDetails: Boolean = false, - val showBackdrop: Boolean = false, - val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, - val showTitles: Boolean = true, - val type: ViewOptionsType = ViewOptionsType.GRID, - val extras: ViewOptionsExtras = ViewOptionsExtras.None, -) { - companion object { - val ViewOptionsColumns = - AppSliderPreference<ViewOptions>( - title = R.string.columns, - defaultValue = 6, - min = 1, - max = 12, - interval = 1, - getter = { it.columns.toLong() }, - setter = { prefs, value -> prefs.copy(columns = value.toInt()) }, - ) - val ViewOptionsSpacing = - AppSliderPreference<ViewOptions>( - title = R.string.spacing, - defaultValue = 16, - min = 0, - max = 32, - interval = 2, - getter = { it.spacing.toLong() }, - setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, - ) - - val ViewOptionsContentScale = - AppChoicePreference<ViewOptions, PrefContentScale>( - title = R.string.global_content_scale, - defaultValue = PrefContentScale.FIT, - displayValues = R.array.content_scale, - getter = { it.contentScale }, - setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, - indexToValue = { PrefContentScale.forNumber(it) }, - valueToIndex = { it.number }, - ) - - val ViewOptionsAspectRatio = - AppChoicePreference<ViewOptions, AspectRatio>( - title = R.string.aspect_ratio, - defaultValue = AspectRatio.TALL, - displayValues = R.array.aspect_ratios, - getter = { it.aspectRatio }, - setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, - indexToValue = { AspectRatio.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsDetailHeader = - AppSwitchPreference<ViewOptions>( - title = R.string.show_details, - defaultValue = false, - getter = { it.showDetails }, - setter = { vo, value -> vo.copy(showDetails = value) }, - ) - val ViewOptionsBackdrop = - AppSwitchPreference<ViewOptions>( - title = R.string.show_backdrop, - defaultValue = false, - getter = { it.showBackdrop }, - setter = { vo, value -> vo.copy(showBackdrop = value) }, - ) - val ViewOptionsShowTitles = - AppSwitchPreference<ViewOptions>( - title = R.string.show_titles, - defaultValue = true, - getter = { it.showTitles }, - setter = { vo, value -> vo.copy(showTitles = value) }, - ) - - val ViewOptionsImageType = - AppChoicePreference<ViewOptions, ViewOptionImageType>( - title = R.string.image_type, - defaultValue = ViewOptionImageType.PRIMARY, - displayValues = R.array.image_types, - getter = { it.imageType }, - setter = { viewOptions, value -> - val aspectRatio = - when (value) { - ViewOptionImageType.PRIMARY -> AspectRatio.TALL - ViewOptionImageType.THUMB -> AspectRatio.WIDE - } - viewOptions.copy(imageType = value, aspectRatio = aspectRatio) - }, - indexToValue = { ViewOptionImageType.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsTypePref = - AppChoicePreference<ViewOptions, ViewOptionsType>( - title = R.string.layout, - defaultValue = ViewOptionsType.GRID, - displayValues = R.array.view_options_types, - getter = { it.type }, - setter = { viewOptions, value -> - val spacing = - when (value) { - ViewOptionsType.GRID -> 16 - ViewOptionsType.LIST -> 4 - ViewOptionsType.DENSE_LIST -> 2 - } - viewOptions.copy(type = value, spacing = spacing) - }, - indexToValue = { ViewOptionsType.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsArtistSource = - AppChoicePreference<ViewOptions, ArtistSource>( - title = R.string.artist_source, - defaultValue = ArtistSource.ARTIST, - displayValues = R.array.artist_sources, - getter = { it.artistSource }, - setter = { viewOptions, value -> - viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value)) - }, - indexToValue = { ArtistSource.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsReset = - AppClickablePreference<ViewOptions>( - title = R.string.reset, - ) - - /** - * Get the options for a grid [ViewOptions] - */ - fun buildGridOptions(showArtistSource: Boolean) = - buildList { - if (showArtistSource) add(ViewOptionsArtistSource) - addAll(GRID_OPTIONS) - } - - private val GRID_OPTIONS = - listOf( - ViewOptionsTypePref, - ViewOptionsImageType, - ViewOptionsAspectRatio, - ViewOptionsDetailHeader, - ViewOptionsBackdrop, - ViewOptionsShowTitles, - ViewOptionsColumns, - ViewOptionsSpacing, - ViewOptionsContentScale, - ViewOptionsReset, - ) - - /** - * Get the options for a list [ViewOptions] - */ - fun buildListOptions(showArtistSource: Boolean) = - buildList { - if (showArtistSource) add(ViewOptionsArtistSource) - addAll(LIST_OPTIONS) - } - - private val LIST_OPTIONS = - listOf( - ViewOptionsTypePref, - ViewOptionsDetailHeader, - ViewOptionsBackdrop, - ViewOptionsSpacing, - ViewOptionsReset, - ) - } -} - -val ViewOptionsPoster = - ViewOptions( - columns = 6, - spacing = 16, - contentScale = PrefContentScale.FILL, - ) -val ViewOptionsWide = - ViewOptions( - columns = 4, - spacing = 24, - contentScale = PrefContentScale.CROP, - aspectRatio = AspectRatio.WIDE, - ) -val ViewOptionsSquare = - ViewOptions( - columns = 6, - spacing = 16, - contentScale = PrefContentScale.FILL, - aspectRatio = AspectRatio.SQUARE, - ) - -enum class ViewOptionImageType( - val imageType: ImageType, -) { - PRIMARY(ImageType.PRIMARY), - THUMB(ImageType.THUMB), -// BANNER(ImageType.BANNER), -} - -enum class ViewOptionsType { - GRID, - 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 - } - -/** - * Extras [ViewOptions] info that is context or library specific - */ -@Serializable -sealed interface ViewOptionsExtras { - @Serializable - data object None : ViewOptionsExtras - - @Serializable - data class MusicLibrary( - val artistSource: ArtistSource = ArtistSource.ARTIST, - ) : ViewOptionsExtras -} - -/** - * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary] - */ -val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST 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 deleted file mode 100644 index 57874b0c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt +++ /dev/null @@ -1,442 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.speech.RecognitionListener -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.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.android.asCoroutineDispatcher -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber -import java.io.Closeable -import javax.inject.Inject -import javax.inject.Singleton - -private const val RMS_DB_MIN = -2.0f -private const val RMS_DB_MAX = 10.0f -private const val MAX_RESULTS = 1 -private const val LISTENING_TIMEOUT_MS = 8000L -private const val RECOGNIZER_RECREATE_DELAY_MS = 150L - -private val ERROR_TO_RESOURCE_MAP = - mapOf( - SpeechRecognizer.ERROR_AUDIO to R.string.voice_error_audio, - SpeechRecognizer.ERROR_CLIENT to R.string.voice_error_client, - SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS to R.string.voice_error_permissions, - SpeechRecognizer.ERROR_NETWORK to R.string.voice_error_network, - SpeechRecognizer.ERROR_NETWORK_TIMEOUT to R.string.voice_error_network_timeout, - SpeechRecognizer.ERROR_NO_MATCH to R.string.voice_error_no_match, - SpeechRecognizer.ERROR_RECOGNIZER_BUSY to R.string.voice_error_busy, - SpeechRecognizer.ERROR_SERVER to R.string.voice_error_server, - SpeechRecognizer.ERROR_SPEECH_TIMEOUT to R.string.voice_error_speech_timeout, - SpeechRecognizer.ERROR_TOO_MANY_REQUESTS to R.string.voice_error_busy, - ) - -private val RETRYABLE_ERRORS = - setOf( - SpeechRecognizer.ERROR_NETWORK, - SpeechRecognizer.ERROR_NETWORK_TIMEOUT, - SpeechRecognizer.ERROR_SERVER, - SpeechRecognizer.ERROR_NO_MATCH, - SpeechRecognizer.ERROR_SPEECH_TIMEOUT, - ) - -private fun normalizeRmsDb(rmsdB: Float) = ((rmsdB - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN)).coerceIn(0f, 1f) - -sealed interface VoiceInputState { - data object Idle : VoiceInputState - - /** Recognizer is being initialized, not yet ready for speech. */ - data object Starting : VoiceInputState - - data object Listening : VoiceInputState - - data object Processing : VoiceInputState - - data class Result( - val text: String, - ) : VoiceInputState - - data class Error( - val messageResId: Int, - val isRetryable: Boolean, - ) : VoiceInputState -} - -@Singleton -class VoiceInputManager - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : Closeable { - private val handler = Handler(Looper.getMainLooper()) - private val mainDispatcher = provideMainDispatcher() - private val scope = CoroutineScope(mainDispatcher + SupervisorJob()) - private val mutex = Mutex() - - private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - private val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - private val audioFocusListener = - AudioManager.OnAudioFocusChangeListener { focusChange -> - when (focusChange) { - AudioManager.AUDIOFOCUS_LOSS -> { - Timber.d("Permanent audio focus loss. Stopping listening.") - stopListening() - } - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.") - } - - else -> { - Timber.d("Audio focus change: $focusChange") - } - } - } - - private val audioFocusRequest: AudioFocusRequest? by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AudioFocusRequest - .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setOnAudioFocusChangeListener(audioFocusListener, handler) - .build() - } else { - null - } - } - - @Suppress("DEPRECATION") - private fun requestAudioFocusCompat(): Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { audioManager.requestAudioFocus(it) } - ?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED - } else { - audioManager.requestAudioFocus( - audioFocusListener, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, - ) - } - - @Suppress("DEPRECATION") - private fun abandonAudioFocusCompat() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - } else { - audioManager.abandonAudioFocus(audioFocusListener) - } - } - - private val _state = MutableStateFlow<VoiceInputState>(VoiceInputState.Idle) - val state: StateFlow<VoiceInputState> = _state.asStateFlow() - - private val _soundLevel = MutableStateFlow(0f) - val soundLevel: StateFlow<Float> = _soundLevel.asStateFlow() - - private val _partialResult = MutableStateFlow("") - val partialResult: StateFlow<String> = _partialResult.asStateFlow() - - val isAvailable = SpeechRecognizer.isRecognitionAvailable(context) - val hasPermission: Boolean - get() = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.RECORD_AUDIO, - ) == PackageManager.PERMISSION_GRANTED - - private var recognizer: SpeechRecognizer? = null - private var timeoutJob: Job? = null - - private fun provideMainDispatcher(): CoroutineDispatcher = - try { - WholphinDispatchers.Main.immediate - } catch (_: IllegalStateException) { - // Fallback for unit tests where Main dispatcher is not installed - handler.asCoroutineDispatcher() - } - - private val recognitionIntent by lazy { - Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) - putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) - putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS) - } - } - - private fun isNetworkAvailable(): Boolean { - val network = connectivityManager.activeNetwork ?: return false - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - } - - fun startListening() { - scope.launch { - mutex.withLock { - val currentState = _state.value - if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) { - return@withLock - } - - val hadRecognizer = recognizer != null - if (hadRecognizer) { - destroyRecognizer() - } - - if (!isNetworkAvailable()) { - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = R.string.voice_error_network, - isRetryable = true, - ) - } - return@withLock - } - - val focusResult = requestAudioFocusCompat() - if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = R.string.voice_error_audio, - isRetryable = true, - ) - } - return@withLock - } - - cancelTimeout() - handler.post { - _partialResult.value = "" - _soundLevel.value = 0f - _state.value = VoiceInputState.Starting - } - - // Give the OS time to release the mic before recreating when replacing an old recognizer - if (hadRecognizer) { - delay(RECOGNIZER_RECREATE_DELAY_MS) - } - - val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context) - recognizer = newRecognizer - newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer)) - - try { - newRecognizer.startListening(recognitionIntent) - } catch (e: Exception) { - Timber.e(e, "Failed to start speech recognition") - destroyRecognizer() - cancelTimeout() - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = R.string.voice_error_start_failed, - isRetryable = true, - ) - } - } - } - } - } - - fun stopListening() { - scope.launch { - mutex.withLock { - cancelTimeout() - close() - } - } - } - - private fun cancelTimeout() { - timeoutJob?.cancel() - timeoutJob = null - } - - private fun startTimeout() { - cancelTimeout() - timeoutJob = - scope.launch { - delay(LISTENING_TIMEOUT_MS) - mutex.withLock { - if (_state.value is VoiceInputState.Listening && recognizer != null) { - val partial = _partialResult.value - destroyRecognizer() - handler.post { - _soundLevel.value = 0f - _partialResult.value = "" - _state.value = - if (partial.isNotBlank()) { - VoiceInputState.Result(partial) - } else { - VoiceInputState.Error( - messageResId = R.string.voice_error_timeout, - isRetryable = true, - ) - } - } - } - } - } - } - - fun acknowledge() { - handler.post { _state.value = VoiceInputState.Idle } - } - - fun onPermissionGranted() = startListening() - - fun onPermissionDenied() { - Timber.w("RECORD_AUDIO permission denied") - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = R.string.voice_error_permissions, - isRetryable = false, - ) - } - } - - private fun destroyRecognizer() { - abandonAudioFocusCompat() - // Null out FIRST to invalidate callbacks before cancel() can trigger them - val rec = recognizer - recognizer = null - rec?.let { - try { - it.cancel() - it.destroy() - } catch (e: Exception) { - Timber.w(e, "Error destroying speech recognizer") - } - } - } - - override fun close() { - destroyRecognizer() - handler.post { - _soundLevel.value = 0f - _partialResult.value = "" - _state.value = VoiceInputState.Idle - } - } - - private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) = - object : RecognitionListener { - // Guard against callbacks from zombie recognizers - private fun isValid() = recognizer === activeRecognizer - - override fun onReadyForSpeech(params: Bundle?) { - if (!isValid()) return - handler.post { _state.value = VoiceInputState.Listening } - startTimeout() - } - - override fun onBeginningOfSpeech() { - if (!isValid()) return - } - - override fun onRmsChanged(rmsdB: Float) { - if (!isValid()) return - handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) } - } - - override fun onBufferReceived(buffer: ByteArray?) = Unit - - override fun onEndOfSpeech() { - if (!isValid()) return - cancelTimeout() - handler.post { _state.value = VoiceInputState.Processing } - } - - override fun onError(error: Int) { - if (!isValid()) return - Timber.e("Voice recognition error code: $error") - cancelTimeout() - destroyRecognizer() - - if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) { - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, - isRetryable = false, - ) - _soundLevel.value = 0f - _partialResult.value = "" - } - return - } - handler.post { - _state.value = - VoiceInputState.Error( - messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, - isRetryable = error in RETRYABLE_ERRORS, - ) - _soundLevel.value = 0f - _partialResult.value = "" - } - } - - override fun onResults(results: Bundle?) { - if (!isValid()) return - cancelTimeout() - val spokenText = - results - ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) - ?.firstOrNull() - handler.post { - _state.value = - if (!spokenText.isNullOrBlank()) { - VoiceInputState.Result(spokenText) - } else { - VoiceInputState.Error( - messageResId = R.string.voice_error_no_match, - isRetryable = true, - ) - } - _soundLevel.value = 0f - } - } - - override fun onPartialResults(partialResults: Bundle?) { - if (!isValid()) return - partialResults - ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) - ?.firstOrNull() - ?.takeIf { it.isNotBlank() } - ?.let { handler.post { _partialResult.value = it } } - } - - override fun onEvent( - eventType: Int, - params: Bundle?, - ) = Unit - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt deleted file mode 100644 index 3042ac06..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt +++ /dev/null @@ -1,414 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.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.requiredSizeIn -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.tv.material3.ButtonDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.OutlinedButton -import androidx.tv.material3.OutlinedButtonDefaults -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.FontAwesome -import kotlinx.coroutines.delay - -private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L -private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f -private val BUBBLE_SIZE = 160.dp -private val MIC_ICON_FONT_SIZE = 56.sp -private val BUTTON_ICON_FONT_SIZE = 20.sp -private val CONTENT_SPACING = 48.dp -private val HORIZONTAL_PADDING = 64.dp -private val DISMISS_HINT_BOTTOM_PADDING = 32.dp -private const val HINT_TEXT_ALPHA = 0.5f -private const val SOUND_LEVEL_ANIM_MS = 100 -private const val BASE_PULSE_ANIM_MS = 800 -private const val RIPPLE_ANIM_MS = 1500 -private const val DOTS_ANIM_MS = 1200 -private const val RIPPLE_CANVAS_SCALE = 1.8f -private const val MAX_RIPPLE_EXPANSION = 0.35f -private val RIPPLE_STROKE_WIDTH = 2.dp -private const val RIPPLE_MAX_ALPHA = 0.4f - -private fun VoiceInputState.shouldShowOverlay() = - this is VoiceInputState.Starting || - this is VoiceInputState.Listening || - this is VoiceInputState.Processing || - this is VoiceInputState.Error - -@Composable -fun VoiceSearchButton( - onSpeechResult: (String) -> Unit, - voiceInputManager: VoiceInputManager?, - modifier: Modifier = Modifier, -) { - if (voiceInputManager == null || !voiceInputManager.isAvailable) return - - val state by voiceInputManager.state.collectAsState() - val soundLevel by voiceInputManager.soundLevel.collectAsState() - val partialResult by voiceInputManager.partialResult.collectAsState() - - LaunchedEffect(state) { - val currentState = state - when (currentState) { - is VoiceInputState.Result -> { - onSpeechResult(currentState.text) - // Small delay to allow focus to be restored before dialog dismisses - delay(50) - voiceInputManager.acknowledge() - } - - is VoiceInputState.Error -> { - if (!currentState.isRetryable) { - delay(ERROR_AUTO_DISMISS_DELAY_MS) - voiceInputManager.acknowledge() - } - } - - else -> {} - } - } - - val permissionLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - if (isGranted) { - voiceInputManager.onPermissionGranted() - } else { - voiceInputManager.onPermissionDenied() - } - } - - if (state.shouldShowOverlay()) { - val errorState = state as? VoiceInputState.Error - val errorMessage = errorState?.messageResId?.let { stringResource(it) } - VoiceSearchOverlay( - soundLevel = soundLevel, - partialResult = partialResult, - isStarting = state is VoiceInputState.Starting, - isProcessing = state is VoiceInputState.Processing, - errorMessage = errorMessage, - isRetryable = errorState?.isRetryable == true, - onRetry = { voiceInputManager.startListening() }, - onDismiss = { voiceInputManager.stopListening() }, - ) - } - - Button( - onClick = { - when (state) { - is VoiceInputState.Starting, - is VoiceInputState.Listening, - -> { - voiceInputManager.stopListening() - } - - else -> { - if (voiceInputManager.hasPermission) { - voiceInputManager.startListening() - } else { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - } - }, - modifier = - modifier.requiredSizeIn( - minWidth = MinButtonSize, - minHeight = MinButtonSize, - maxWidth = MinButtonSize, - maxHeight = MinButtonSize, - ), - contentPadding = PaddingValues(0.dp), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - val voiceSearchDesc = stringResource(R.string.voice_search) - Text( - text = stringResource(R.string.fa_microphone), - fontFamily = FontAwesome, - fontSize = BUTTON_ICON_FONT_SIZE, - textAlign = TextAlign.Center, - modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, - ) - } - } -} - -@Composable -private fun VoiceRippleRings( - rippleProgress: Float, - bubbleSize: androidx.compose.ui.unit.Dp, - color: Color, - modifier: Modifier = Modifier, -) { - val density = LocalDensity.current - val rippleStroke = - remember(density) { - Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() }) - } - - Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) { - val canvasCenter = center - val baseRadius = bubbleSize.toPx() / 2 - val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION - - for (i in 0..2) { - val ringProgress = (rippleProgress + (i * 0.33f)) % 1f - val ringRadius = baseRadius + (ringProgress * maxExpansion) - val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA - drawCircle( - color = color.copy(alpha = ringAlpha), - radius = ringRadius, - center = canvasCenter, - style = rippleStroke, - ) - } - } -} - -private fun getStatusText( - errorMessage: String?, - partialResult: String, - isStarting: Boolean, - isProcessing: Boolean, - startingText: String, - processingText: String, - listeningText: String, - dotCount: Int, -): Pair<String, String> { - val dots = ".".repeat(dotCount) - return when { - errorMessage != null -> errorMessage to errorMessage - isProcessing -> (processingText + dots) to processingText - isStarting -> (startingText + dots) to startingText - partialResult.isNotBlank() -> partialResult to partialResult - else -> (listeningText + dots) to listeningText - } -} - -@Composable -private fun VoiceSearchOverlay( - soundLevel: Float, - partialResult: String, - isStarting: Boolean, - isProcessing: Boolean, - errorMessage: String?, - isRetryable: Boolean, - onRetry: () -> Unit, - onDismiss: () -> Unit, -) { - val primaryColor = MaterialTheme.colorScheme.primary - val onPrimaryColor = MaterialTheme.colorScheme.onPrimary - val errorColor = MaterialTheme.colorScheme.error - - val animatedSoundLevel by animateFloatAsState( - targetValue = soundLevel, - animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS), - label = "soundLevel", - ) - - val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val basePulse by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 1.05f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = BASE_PULSE_ANIM_MS), - repeatMode = RepeatMode.Reverse, - ), - label = "basePulse", - ) - - // Only animate ripples when actively listening (not starting, processing, or in error) - val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null - val rippleProgress by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = if (shouldAnimateRipples) 1f else 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = RIPPLE_ANIM_MS), - repeatMode = RepeatMode.Restart, - ), - label = "ripple", - ) - - val dotAnimation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 4f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = DOTS_ANIM_MS), - repeatMode = RepeatMode.Restart, - ), - label = "dots", - ) - - val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR) - - val statusFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - statusFocusRequester.requestFocus() - } - - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true, - usePlatformDefaultWidth = false, - ), - ) { - Box( - modifier = - Modifier - .fillMaxWidth(0.85f) - .wrapContentHeight() - .padding(vertical = 48.dp) - .clip(MaterialTheme.shapes.large) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), - contentAlignment = Alignment.Center, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING), - ) { - Box(contentAlignment = Alignment.Center) { - val rippleAlpha = if (shouldAnimateRipples) 1f else 0f - Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) { - VoiceRippleRings( - rippleProgress = rippleProgress, - bubbleSize = BUBBLE_SIZE, - color = primaryColor, - ) - } - - Box( - modifier = - Modifier - .size(BUBBLE_SIZE) - .graphicsLayer { - scaleX = bubbleScale - scaleY = bubbleScale - }.clip(CircleShape) - .background(if (errorMessage != null) errorColor else primaryColor), - contentAlignment = Alignment.Center, - ) { - val voiceSearchDesc = stringResource(R.string.voice_search) - Text( - text = stringResource(R.string.fa_microphone), - fontFamily = FontAwesome, - fontSize = MIC_ICON_FONT_SIZE, - color = onPrimaryColor, - textAlign = TextAlign.Center, - modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, - ) - } - } - - val startingText = stringResource(R.string.voice_starting) - val processingText = stringResource(R.string.processing) - val listeningText = stringResource(R.string.voice_search_prompt) - val (statusText, accessibilityDescription) = - getStatusText( - errorMessage = errorMessage, - partialResult = partialResult, - isStarting = isStarting, - isProcessing = isProcessing, - startingText = startingText, - processingText = processingText, - listeningText = listeningText, - dotCount = dotAnimation.toInt(), - ) - - Column( - modifier = Modifier.weight(1f).focusRequester(statusFocusRequester), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = statusText, - style = MaterialTheme.typography.headlineMedium, - color = if (errorMessage != null) errorColor else Color.White, - modifier = Modifier.semantics { contentDescription = accessibilityDescription }, - ) - - if (errorMessage != null && isRetryable) { - OutlinedButton( - onClick = onRetry, - modifier = Modifier.padding(top = 16.dp), - shape = ButtonDefaults.shape(RoundedCornerShape(50)), - colors = - OutlinedButtonDefaults.colors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text( - text = stringResource(R.string.retry), - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - - Text( - text = stringResource(R.string.press_back_to_cancel), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA), - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = DISMISS_HINT_BOTTOM_PADDING), - ) - } - } -} 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 4dc1dd16..009c4268 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 @@ -2,50 +2,38 @@ package com.github.damontecres.wholphin.ui.data import android.content.Context import android.widget.Toast +import androidx.lifecycle.MutableLiveData 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.setValueOnMain 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.api.client.ApiClient -import timber.log.Timber +import org.jellyfin.sdk.model.api.MediaType import java.util.UUID import javax.inject.Inject -/** - * A supplementary [ViewModel] for adding items to a server playlist - * @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog - */ @HiltViewModel 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) + val playlistState = MutableLiveData<PlaylistLoadingState>(PlaylistLoadingState.Pending) - fun loadPlaylists(query: String = "") { + fun loadPlaylists(mediaType: MediaType?) { viewModelScope.launchIO { - this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading + this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading) try { - val playlists = playlistCreator.getServerPlaylists(query, null) - this@AddPlaylistViewModel.playlistState.value = - PlaylistLoadingState.Success(playlists, query) + val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) + this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists)) } catch (ex: Exception) { - playlistState.value = PlaylistLoadingState.Error(ex) + playlistState.setValueOnMain(PlaylistLoadingState.Error(ex)) } } } @@ -55,13 +43,7 @@ class AddPlaylistViewModel itemId: UUID, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - try { - playlistCreator.addToServerPlaylist(playlistId, itemId) - showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) - } catch (ex: Exception) { - Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId) - showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT) - } + playlistCreator.addToServerPlaylist(playlistId, itemId) } } @@ -73,15 +55,7 @@ class AddPlaylistViewModel val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId)) if (playlistId == null) { showToast(context, "Error creating playlist", Toast.LENGTH_LONG) - } else { - showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) } } } - - 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 deleted file mode 100644 index 397a2126..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ChooseVersionParams.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 08bd0c55..d305bfe1 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 @@ -1,493 +1,156 @@ package com.github.damontecres.wholphin.ui.data -import android.content.res.Resources import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle 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.studioNames +import com.github.damontecres.wholphin.ui.byteRateSuffixes 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 -import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName +import org.jellyfin.sdk.model.api.AudioSpatialFormat 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.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType -import org.jellyfin.sdk.model.extensions.ticks -import java.time.LocalDateTime import java.util.Locale data class ItemDetailsDialogInfo( val title: String, val overview: String?, - val genres: List<String>, val files: List<MediaSourceInfo>, - val studios: List<String> = emptyList(), - val lastPlayed: LocalDateTime? = null, -) { - constructor(item: BaseItem) : this( - title = item.name ?: "", - overview = item.data.overview, - genres = item.data.genres.orEmpty(), - files = item.data.mediaSources.orEmpty(), - studios = item.studioNames, - lastPlayed = item.data.userData?.lastPlayedDate, - ) -} +) -/** - * Dialog showing metadata about an item - */ @Composable fun ItemDetailsDialog( info: ItemDetailsDialogInfo, showFilePath: Boolean, onDismissRequest: () -> Unit, +) = ScrollableDialog( + onDismissRequest = onDismissRequest, ) { - val resources = LocalResources.current - // Extract stringResource calls outside of ScrollableDialog's non-composable lambda - val pathLabel = stringResource(R.string.path) - val fileSizeLabel = stringResource(R.string.file_size) - val videoLabel = stringResource(R.string.video) - val audioLabel = stringResource(R.string.audio) - val subtitleLabel = stringResource(R.string.subtitle) - val bitrateLabel = stringResource(R.string.bitrate) - val unknown = stringResource(R.string.unknown) - val runtimeLabel = stringResource(R.string.runtime_sort) - val lastPlayedLabel = stringResource(R.string.last_played) - - ScrollableDialog( - onDismissRequest = onDismissRequest, - width = 680.dp, - maxHeight = 440.dp, - itemSpacing = 8.dp, - ) { + item { + Text( + text = info.title, + style = MaterialTheme.typography.titleLarge, + ) + } + if (info.overview.isNotNullOrBlank()) { item { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = info.title, - style = MaterialTheme.typography.headlineSmall, - ) - if (info.studios.isNotEmpty()) { - Text( - text = info.studios.joinToString(", "), - style = MaterialTheme.typography.titleMedium, - ) - } - if (info.genres.isNotEmpty()) { - Text( - text = info.genres.joinToString(", "), - style = MaterialTheme.typography.titleSmall, - ) - } - if (info.overview.isNotNullOrBlank()) { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyMedium, - ) - } - val lastPlayed = - remember(info.lastPlayed) { info.lastPlayed?.let { formatDate(it) } } - if (lastPlayed != null) { - Text( - text = "$lastPlayedLabel: $lastPlayed", - style = MaterialTheme.typography.bodyMedium, - ) - } - } + Text( + text = info.overview, + style = MaterialTheme.typography.bodyLarge, + ) } - - // Show detailed media information for the selected source (first one if multiple) - info.files.forEachIndexed { index, source -> - source.mediaStreams?.letNotEmpty { mediaStreams -> - item { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - } - - // General file information - item { - val containerLabel = stringResource(R.string.container) - MediaInfoSection( - title = - titleIndex( - stringResource(R.string.general), - index, - info.files.size, - ), - items = - buildList { - source.container?.let { add(containerLabel to it) } - if (showFilePath) { - source.path?.let { add(pathLabel to it) } - add("ID" to (source.id ?: unknown)) - } - source.size?.let { - add(fileSizeLabel to formatBytes(it)) - } - source.bitrate?.let { - add( - bitrateLabel to formatBitrate(it), - ) - } - source.runTimeTicks?.let { - add(runtimeLabel to resources.formatDuration(it.ticks)) - } - }, - ) - } - - // Video streams - val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO } - itemsIndexed(videoStreams) { index, stream -> - MediaInfoSection( - title = titleIndex(videoLabel, index, videoStreams.size), - items = remember { buildVideoStreamInfo(resources, stream) }, - additional = remember { buildVideoStreamInfoAdditional(resources, stream) }, - ) - } - - // Audio streams - display multiple per row - val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO } - itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - streamGroup.forEachIndexed { index, stream -> - MediaInfoSection( - title = - titleIndex( - audioLabel, - groupIndex * 3 + index, - audioStreams.size, - ), - items = buildAudioStreamInfo(resources, stream), - modifier = Modifier.weight(1f), - ) - } - // Fill remaining space if less than 3 items - repeat(3 - streamGroup.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - - // Subtitle streams - display multiple per row - val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE } - itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - streamGroup.forEachIndexed { index, stream -> - MediaInfoSection( - title = - titleIndex( - subtitleLabel, - groupIndex * 3 + index, - subtitleStreams.size, - ), - items = buildSubtitleStreamInfo(resources, stream, showFilePath), - modifier = Modifier.weight(1f), - ) - } - // Fill remaining space if less than 3 items - repeat(3 - streamGroup.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - if (index != info.files.lastIndex) { - item { - Spacer(Modifier.height(8.dp)) - } - } - } + } + if (info.files.isNotEmpty()) { + item { + Spacer(Modifier.height(12.dp)) + } + } + items(info.files) { file -> + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + MediaSourceInfo(file, showFilePath, Modifier.padding(top = 12.dp)) } } } @Composable -private fun MediaInfoSection( - title: String, - items: List<Pair<String, String>>, +fun MediaSourceInfo( + source: MediaSourceInfo, + showFilePath: Boolean, modifier: Modifier = Modifier, - additional: List<Pair<String, String>> = listOf(), ) { Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - modifier = modifier.padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, ) { Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.name) + ": ${source.name}", ) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier.padding(start = 12.dp), - ) { - Column(modifier = Modifier.weight(1f, fill = false)) { - items.forEach { (label, value) -> - Row( - modifier = Modifier, - ) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + Text( + text = "ID: ${source.id}", + ) + if (showFilePath) { + Text( + text = stringResource(R.string.path) + ": ${source.path}", + ) + } + source.size?.let { size -> + Text( + text = stringResource(R.string.file_size) + ": ${formatBytes(size)}", + ) + } + source.bitrate?.let { bitrate -> + Text( + text = + stringResource(R.string.bitrate) + ": ${ + formatBytes( + bitrate, + byteRateSuffixes, ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - if (additional.isNotEmpty()) { - Column(modifier = Modifier.weight(1f, fill = false)) { - additional.forEach { (label, value) -> - Row( - modifier = Modifier, - ) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - ) + }", + ) + } + source.mediaStreams?.letNotEmpty { streams -> + streams.filter { it.type == MediaStreamType.VIDEO }.forEach { stream -> + val data = + buildList { + if (stream.width != null && stream.height != null) { + add("${stream.width}x${stream.height}") } + stream.averageFrameRate?.let { + add(String.format(Locale.getDefault(), "%.3f", it) + " fps") + } + stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } + stream.codec?.let(::add) + stream.profile?.let(::add) } - } + Text( + text = stringResource(R.string.video) + ": " + data.joinToString(" - "), + ) + } + + streams.filter { it.type == MediaStreamType.AUDIO }.forEachIndexed { index, stream -> + val data = + buildList { + stream.language?.let { add(languageName(it)) } + stream.codec?.let(::add) + stream.channelLayout?.let(::add) + stream.bitRate?.let { add(formatBytes(it, byteRateSuffixes)) } + if (stream.audioSpatialFormat != AudioSpatialFormat.NONE) add(stream.audioSpatialFormat.serialName) + if (stream.isDefault) add(stringResource(R.string.default_track)) + } + Text( + text = stringResource(R.string.audio) + " ${index + 1}: " + data.joinToString(" - "), + ) + } + + streams.filter { it.type == MediaStreamType.SUBTITLE }.forEachIndexed { index, stream -> + val data = + buildList { + stream.language?.let { add(languageName(it)) } + stream.codec?.let(::add) + if (stream.isDefault) add(stringResource(R.string.default_track)) + if (stream.isForced) add(stringResource(R.string.forced_track)) + if (stream.isExternal) add(stringResource(R.string.external_track)) + } + Text( + text = + stringResource(R.string.subtitle) + " ${index + 1}: " + + data.joinToString(" - "), + ) } } } } - -fun titleIndex( - title: String, - index: Int, - total: Int, -) = if (total > 1) { - "$title (${index + 1})" -} else { - title -} - -private fun buildVideoStreamInfo( - resources: Resources, - stream: MediaStream, -): List<Pair<String, String>> = - buildList { - val yesStr = resources.getString(R.string.yes) - val noStr = resources.getString(R.string.no) - - val titleLabel = resources.getString(R.string.title) - val codecLabel = resources.getString(R.string.codec) - val resolutionLabel = resources.getString(R.string.resolution) - val aspectRatioLabel = resources.getString(R.string.aspect_ratio) - val framerateLabel = resources.getString(R.string.framerate) - val bitrateLabel = resources.getString(R.string.bitrate) - val profileLabel = resources.getString(R.string.profile) - val levelLabel = resources.getString(R.string.level) - val interlacedLabel = resources.getString(R.string.interlaced) - val videoRangeLabel = resources.getString(R.string.video_range) - val sdrStr = resources.getString(R.string.sdr) - val hdrStr = resources.getString(R.string.hdr) - - stream.title?.let { add(titleLabel to it) } - stream.codec?.let { add(codecLabel to it.uppercase()) } - if (stream.width != null && stream.height != null) { - add(resolutionLabel to "${stream.width}x${stream.height}") - } - if (stream.width != null && stream.height != null) { - val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) - add(aspectRatioLabel to aspectRatio) - } - stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } - stream.averageFrameRate?.let { - add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) - } - - stream.videoRange.let { - val rangeStr = - when (it) { - VideoRange.SDR -> sdrStr - VideoRange.HDR -> hdrStr - VideoRange.UNKNOWN -> null - } - rangeStr?.let { add(videoRangeLabel to it) } - } - stream.profile?.let { add(profileLabel to it) } - stream.level?.let { add(levelLabel to it.toString()) } - stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) } - } - -private fun buildVideoStreamInfoAdditional( - resources: Resources, - stream: MediaStream, -): List<Pair<String, String>> = - buildList { - val yesStr = resources.getString(R.string.yes) - val noStr = resources.getString(R.string.no) - - val avcLabel = resources.getString(R.string.avc) - val anamorphicLabel = resources.getString(R.string.anamorphic) - val bitDepthLabel = resources.getString(R.string.bit_depth) - - val videoRangeTypeLabel = resources.getString(R.string.video_range_type) - val colorSpaceLabel = resources.getString(R.string.color_space) - val colorTransferLabel = resources.getString(R.string.color_transfer) - val colorPrimariesLabel = resources.getString(R.string.color_primaries) - val pixelFormatLabel = resources.getString(R.string.pixel_format) - val refFramesLabel = resources.getString(R.string.ref_frames) - val nalLabel = resources.getString(R.string.nal) - val dolbyVisionLabel = resources.getString(R.string.dolby_vision) - - val sdrStr = resources.getString(R.string.sdr) - val hdr10Str = resources.getString(R.string.hdr10) - val hdr10PlusStr = resources.getString(R.string.hdr10_plus) - val hlgStr = resources.getString(R.string.hlg) - val bitUnit = resources.getString(R.string.bit_unit) - - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } - stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } - stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } - stream.videoRangeType?.let { - val rangeTypeStr = - when (it) { - VideoRangeType.SDR -> sdrStr - - VideoRangeType.HDR10 -> hdr10Str - - VideoRangeType.HDR10_PLUS -> hdr10PlusStr - - VideoRangeType.HLG -> hlgStr - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> resources.getString(R.string.dolby_vision) - - VideoRangeType.UNKNOWN -> null - - else -> null - } - rangeTypeStr?.let { add(videoRangeTypeLabel to it) } - } - stream.colorSpace?.let { add(colorSpaceLabel to it) } - stream.colorTransfer?.let { add(colorTransferLabel to it) } - stream.colorPrimaries?.let { add(colorPrimariesLabel to it) } - stream.pixelFormat?.let { add(pixelFormatLabel to it) } - stream.refFrames?.let { add(refFramesLabel to it.toString()) } - stream.nalLengthSize?.let { add(nalLabel to it) } - stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) } - } - -private fun buildAudioStreamInfo( - resources: android.content.res.Resources, - stream: MediaStream, -): List<Pair<String, String>> = - buildList { - val titleLabel = resources.getString(R.string.title) - val languageLabel = resources.getString(R.string.language) - val codecLabel = resources.getString(R.string.codec) - val layoutLabel = resources.getString(R.string.layout) - val channelsLabel = resources.getString(R.string.channels) - val bitrateLabel = resources.getString(R.string.bitrate) - val sampleRateLabel = resources.getString(R.string.sample_rate) - val defaultLabel = resources.getString(R.string.default_track) - val profileLabel = resources.getString(R.string.profile) - val yesStr = resources.getString(R.string.yes) - val noStr = resources.getString(R.string.no) - val sampleRateUnit = resources.getString(R.string.sample_rate_unit) - - stream.title?.let { add(titleLabel to it) } - stream.language?.let { add(languageLabel to languageName(it)) } - stream.codec?.let { - val formattedCodec = formatAudioCodec(resources, it, stream.profile) + " ($it)" - add(codecLabel to formattedCodec) - } - stream.channelLayout?.let { add(layoutLabel to it) } - stream.channels?.let { add(channelsLabel to it.toString()) } - stream.profile?.let { add(profileLabel to it) } - stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } - stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } - stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } - } - -private fun buildSubtitleStreamInfo( - resources: Resources, - stream: MediaStream, - showPath: Boolean, -): List<Pair<String, String>> = - buildList { - val titleLabel = resources.getString(R.string.title) - val languageLabel = resources.getString(R.string.language) - val codecLabel = resources.getString(R.string.codec) - val defaultLabel = resources.getString(R.string.default_track) - val forcedLabel = resources.getString(R.string.forced_track) - val externalLabel = resources.getString(R.string.external_track) - val yesStr = resources.getString(R.string.yes) - val noStr = resources.getString(R.string.no) - val pathLabel = resources.getString(R.string.path) - - stream.title?.let { add(titleLabel to it) } - stream.language?.let { add(languageLabel to languageName(it)) } - stream.codec?.let { - val formattedCodec = formatSubtitleCodec(it) + " ($it)" - add(codecLabel to formattedCodec) - } - stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } - stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) } - stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) } - stream.isHearingImpaired?.let { - add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) - } - if (showPath) { - stream.path?.let { add(pathLabel to it) } - } - } - -private fun calculateAspectRatio( - width: Int, - height: Int, -): String { - val gcd = gcd(width, height) - val w = width / gcd - val h = height / gcd - return "$w:$h" -} - -private fun gcd( - a: Int, - b: Int, -): Int = if (b == 0) a else gcd(b, a % b) 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 36b4e5c7..e86acbad 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,12 +1,8 @@ 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 @@ -48,9 +44,8 @@ val SeriesSortOptions = ItemSortBy.PREMIERE_DATE, ItemSortBy.DATE_CREATED, ItemSortBy.DATE_LAST_CONTENT_ADDED, - ItemSortBy.SERIES_DATE_PLAYED, + ItemSortBy.DATE_PLAYED, ItemSortBy.COMMUNITY_RATING, - ItemSortBy.CRITIC_RATING, ItemSortBy.OFFICIAL_RATING, ItemSortBy.RANDOM, ) @@ -58,27 +53,19 @@ 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, @@ -86,73 +73,17 @@ val VideoSortOptions = val PlaylistSortOptions = listOf( - ItemSortBy.DEFAULT, 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, ) -val BoxSetSortOptions = - listOf( - ItemSortBy.DEFAULT, - 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, - ) - -val AlbumSortOptions = - listOf( - ItemSortBy.SORT_NAME, - ItemSortBy.PREMIERE_DATE, - ItemSortBy.DATE_CREATED, - ItemSortBy.DATE_PLAYED, - ItemSortBy.ALBUM_ARTIST, - ItemSortBy.COMMUNITY_RATING, - ItemSortBy.CRITIC_RATING, - ItemSortBy.RANDOM, - ) - -val ArtistSortOptions = - listOf( - ItemSortBy.SORT_NAME, - ItemSortBy.PREMIERE_DATE, - ItemSortBy.DATE_CREATED, - ItemSortBy.DATE_PLAYED, - ItemSortBy.COMMUNITY_RATING, - ItemSortBy.CRITIC_RATING, - ItemSortBy.RANDOM, - ) - -val SongSortOptions = - listOf( - ItemSortBy.SORT_NAME, - ItemSortBy.PREMIERE_DATE, - ItemSortBy.ALBUM, - ItemSortBy.ALBUM_ARTIST, - ItemSortBy.ARTIST, - ItemSortBy.DATE_CREATED, - ItemSortBy.DATE_PLAYED, - ItemSortBy.COMMUNITY_RATING, - ItemSortBy.CRITIC_RATING, - ItemSortBy.PLAY_COUNT, - ItemSortBy.RUNTIME, - ItemSortBy.RANDOM, - ) - @StringRes fun getStringRes(sort: ItemSortBy): Int = when (sort) { @@ -161,9 +92,7 @@ fun getStringRes(sort: ItemSortBy): Int = -> R.string.sort_by_name ItemSortBy.PREMIERE_DATE -> R.string.sort_by_date_released - ItemSortBy.DATE_CREATED -> R.string.sort_by_date_added - ItemSortBy.DATE_LAST_CONTENT_ADDED -> R.string.sort_by_date_episode_added ItemSortBy.DATE_PLAYED, @@ -171,54 +100,12 @@ fun getStringRes(sort: ItemSortBy): Int = -> R.string.sort_by_date_played ItemSortBy.RANDOM -> R.string.sort_by_random - ItemSortBy.COMMUNITY_RATING -> R.string.community_rating - ItemSortBy.CRITIC_RATING -> R.string.critic_rating - ItemSortBy.OFFICIAL_RATING -> R.string.official_rating - ItemSortBy.PLAY_COUNT -> R.string.play_count - ItemSortBy.AIRED_EPISODE_ORDER -> R.string.aired_episode_order - ItemSortBy.RUNTIME -> R.string.runtime_sort - ItemSortBy.DEFAULT -> R.string.default_track - - ItemSortBy.ALBUM -> R.string.album - - ItemSortBy.ALBUM_ARTIST -> R.string.album_artist - - ItemSortBy.ARTIST -> R.string.artist - 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 00713ddb..b83ee031 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 @@ -1,12 +1,9 @@ package com.github.damontecres.wholphin.ui.detail -import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -14,9 +11,7 @@ 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.Spacer 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.layout.width @@ -26,86 +21,60 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip 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.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.layout.onLayoutRectChanged -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Button -import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.LocalContentColor 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.AppColors import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton 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 com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import kotlin.math.ceil private const val DEBUG = false -interface CardGridItem { - val gridId: String - val playable: Boolean - val sortName: String -} - -data class GridItemDetails<T : CardGridItem>( - val item: T?, - val index: Int, - val onClick: () -> Unit, - val onLongClick: () -> Unit, - val widthPx: Int, - val mod: Modifier, -) - -/** - * Shows a vertical grid of [CardGridItem]s - */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun <T : CardGridItem> CardGrid( - pager: List<T?>, - onClickItem: (Int, T) -> Unit, - onLongClickItem: (Int, T) -> Unit, - onClickPlay: (Int, T) -> Unit, +fun CardGrid( + pager: List<BaseItem?>, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, gridFocusRequester: FocusRequester, showJumpButtons: Boolean, @@ -113,39 +82,41 @@ fun <T : CardGridItem> CardGrid( modifier: Modifier = Modifier, initialPosition: Int = 0, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - cardContent: @Composable (GridItemDetails<T>) -> Unit, + cardContent: @Composable ( + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + mod: Modifier, + ) -> Unit = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = ContentScale.FillBounds, + modifier = mod, + ) + }, columns: Int = 6, spacing: Dp = 16.dp, - bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { - val startPosition = - remember(initialPosition, pager.size) { - initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) - } + val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) - var focusedIndex by rememberSaveable { mutableIntStateOf(startPosition) } - val currentFocusedIndex by rememberUpdatedState(focusedIndex) - val gridState = - rememberLazyGridState( - cacheWindow = LazyLayoutCacheWindow(aheadFraction = 2f, behindFraction = 0.5f), - initialFirstVisibleItemIndex = focusedIndex, - ) + val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) + val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow) val scope = rememberCoroutineScope() val firstFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() } var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } var alphabetFocus by remember { mutableStateOf(false) } - val focusOn = - remember { - { index: Int -> - if (DEBUG) Timber.v("focusOn: focusedIndex=$currentFocusedIndex, index=$index") - if (index != currentFocusedIndex) { - previouslyFocusedIndex = currentFocusedIndex - } - focusedIndex = index - } + val focusOn = { index: Int -> + if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index") + if (index != focusedIndex) { + previouslyFocusedIndex = focusedIndex } + focusedIndex = index + } // Wait for a recomposition to focus val alphabetFocusRequester = remember { FocusRequester() } @@ -184,292 +155,228 @@ fun <T : CardGridItem> CardGrid( } } - val jump = - remember { - { jump: Int -> - scope.launch(ExceptionHandler()) { - val newPosition = - (gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size) - if (DEBUG) Timber.d("newPosition=$newPosition") - focusOn(newPosition) - gridState.scrollToItem(newPosition, 0) - } - } + val jump = { jump: Int -> + scope.launch(ExceptionHandler()) { + val newPosition = + (gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size) + if (DEBUG) Timber.d("newPosition=$newPosition") + focusOn(newPosition) + gridState.scrollToItem(newPosition, 0) } - val jumpToTop = - remember { - { - scope.launch(ExceptionHandler()) { - if (currentFocusedIndex < (columns * 6)) { - // If close, animate the scroll - gridState.animateScrollToItem(0, 0) - } else { - gridState.scrollToItem(0, 0) - } - focusOn(0) - zeroFocus.tryRequestFocus() - } + } + val jumpToTop = { + scope.launch(ExceptionHandler()) { + if (focusedIndex < (columns * 6)) { + // If close, animate the scroll + gridState.animateScrollToItem(0, 0) + } else { + gridState.scrollToItem(0, 0) } + focusOn(0) + zeroFocus.tryRequestFocus() } + } - val jumpToLetter: (Char) -> Unit = - remember { - { letter: Char -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(WholphinDispatchers.IO) { - letterPosition.invoke(letter) + var longPressing by remember { mutableStateOf(false) } + Row( +// horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxSize() + .onKeyEvent { + if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") + if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { + longPressing = true + 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() + } } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true + return@onKeyEvent true + } else if (it.type == KeyEventType.KeyUp) { + if (longPressing && it.key == Key.Back) { + longPressing = false + return@onKeyEvent true + } + longPressing = false } - } - } - } - - if (pager.isEmpty()) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.fillMaxSize(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, + if (it.type != KeyEventType.KeyUp) { + return@onKeyEvent false + } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { + jumpToTop() + return@onKeyEvent true + } else if (isPlayKeyUp(it)) { + // TODO play the focused item + return@onKeyEvent true + } else if (useJumpRemoteButtons && isForwardButton(it)) { + jump(jump1) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isBackwardButton(it)) { + jump(-jump1) + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }, + ) { + if (showJumpButtons && pager.isNotEmpty()) { + JumpButtons( + jump1 = jump1, + jump2 = jump2, + jumpClick = { jump(it) }, + modifier = Modifier.align(Alignment.CenterVertically), ) } - } else { - var longPressing by remember { mutableStateOf(false) } - Row( -// horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .fillMaxSize() - .onKeyEvent { - if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") - if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { - longPressing = true - val newPosition = previouslyFocusedIndex - if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") - if (newPosition > 0) { - scope.launch(ExceptionHandler()) { - pager.getOrNull(newPosition) - gridState.scrollToItem(newPosition) - focusOn(newPosition) - alphabetFocus = true + Box( + modifier = Modifier.weight(1f), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position +// focusedIndex = -1 + } + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition } } - return@onKeyEvent true - } else if (it.type == KeyEventType.KeyUp) { - if (longPressing && it.key == Key.Back) { - longPressing = false - return@onKeyEvent true - } - longPressing = false - } - if (it.type != KeyEventType.KeyUp) { - return@onKeyEvent false - } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { - jumpToTop() - return@onKeyEvent true - } else if (isPlayKeyUp(it)) { - val item = pager.getOrNull(focusedIndex) - if (item?.playable == true) { - Timber.v("Clicked play on ${item.gridId}") - onClickPlay.invoke(focusedIndex, item) - } - return@onKeyEvent true - } else if (useJumpRemoteButtons && isForwardButton(it)) { - jump(jump1) - return@onKeyEvent true - } else if (useJumpRemoteButtons && isBackwardButton(it)) { - jump(-jump1) - return@onKeyEvent true - } else if (showLetterButtons && pager.isNotEmpty() && - it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z) - ) { - val letter = it.nativeKeyEvent.unicodeChar.toChar() - jumpToLetter.invoke(letter) - return@onKeyEvent true - } else { - return@onKeyEvent false - } - }, - ) { - if (showJumpButtons && pager.isNotEmpty()) { - JumpButtons( - jump1 = jump1, - jump2 = jump2, - jumpClick = { jump(it) }, - modifier = Modifier.align(Alignment.CenterVertically), - ) - } - val density = LocalDensity.current - var cardWidthPx by rememberSaveable { mutableIntStateOf(0) } - - Box( - modifier = Modifier.weight(1f), + }, ) { - CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition - } - } - }.onLayoutRectChanged(0, 0) { - val width = it.width - val spacingPx = with(density) { spacing.toPx() } - val cardWidth = - ceil((width - (spacingPx * (columns - 1))) / columns) - cardWidthPx = cardWidth.toInt() -// Timber.v("cardWidthPx=%s", cardWidthPx) - }, - ) { - items(pager.size) { index -> - val item = pager[index] - val details = - remember(index, item, cardWidthPx, columns) { - val mod = - if ((index == currentFocusedIndex) or (currentFocusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - GridItemDetails( - item = item, - index = index, - onClick = { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - onLongClick = { - if (item != null) { - onLongClickItem.invoke(index, item) - } - }, - widthPx = cardWidthPx, - mod = - mod - .ifElse( - index == 0, - Modifier.focusRequester(zeroFocus), - ).onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) - } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } - }, + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", ) } - cardContent.invoke(details) - } - } - if (pager.isEmpty()) { + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { +// savedFocusedIndex = index +// // Was focused on this, so mark unfocused +// focusedIndex = -1 + } + }, + ) + } + } + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) - } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } + } + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } + } + } + val context = LocalContext.current + val letters = context.getString(R.string.jump_letters) + // Letters + val currentLetter = + remember(focusedIndex) { + pager + .getOrNull(focusedIndex) + ?.data + ?.sortName + ?.first() + ?.uppercaseChar() + ?.let { + if (it >= '0' && it <= '9') { + '#' + } else if (it >= 'A' && it <= 'Z') { + it + } else { + null } } - } + ?: letters[0] } - val letters = stringResource(R.string.jump_letters) - // Letters - val currentLetter = - remember(focusedIndex) { - pager - .getOrNull(focusedIndex) - ?.sortName - ?.firstOrNull() - ?.uppercaseChar() - ?.let { - when (it) { - in '0'..'9' -> '#' - in 'A'..'Z' -> it - else -> null + if (showLetterButtons && pager.isNotEmpty()) { + AlphabetButtons( + letters = letters, + currentLetter = currentLetter, + modifier = Modifier.align(Alignment.CenterVertically), + letterClicked = { letter -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true } - ?: letters[0] - } - if (showLetterButtons && pager.isNotEmpty()) { - AlphabetButtons( - letters = letters, - currentLetter = currentLetter, - modifier = - Modifier - .align(Alignment.CenterVertically) - .padding(start = 16.dp), - // Add end padding to push away from edge - letterClicked = jumpToLetter, - ) - } else { - // Spacer ensures card sizes do not change if the alphabet buttons are not shown - Spacer( - Modifier - .padding(start = 16.dp) - .width(letterHorizontalPadding * 2 + letterButtonSize), - ) - } + } + }, + ) } } } -private val letterHorizontalPadding = 2.dp -private val letterButtonSize = 14.dp - @Composable fun JumpButtons( jump1: Int, @@ -512,10 +419,11 @@ fun AlphabetButtons( letterClicked: (Char) -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val index = remember(letters, currentLetter) { letters.indexOf(currentLetter) } + val index = letters.indexOf(currentLetter) LaunchedEffect(currentLetter) { scope.launch(ExceptionHandler()) { val firstVisibleItemIndex = listState.firstVisibleItemIndex @@ -523,97 +431,48 @@ fun AlphabetButtons( listState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index ?: -1 - if (index >= 0 && index !in firstVisibleItemIndex..lastVisibleItemIndex) { + if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) { listState.animateScrollToItem(index) } } } - // Focus & interaction states for each letter button val focusRequesters = remember { List(letters.length) { FocusRequester() } } - val interactionSources = remember { List(letters.length) { MutableInteractionSource() } } - - // Track if the entire alphabet picker component has focus - var alphabetPickerFocused by remember { mutableStateOf(false) } - LazyColumn( - contentPadding = PaddingValues(vertical = 1.1.dp, horizontal = letterHorizontalPadding), - verticalArrangement = Arrangement.spacedBy(1.1.dp), state = listState, modifier = - modifier - .onFocusChanged { focusState -> - alphabetPickerFocused = focusState.hasFocus - }.focusProperties { - onEnter = { - focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() - } - }, + modifier.focusProperties { + onEnter = { + focusRequesters[index.coerceIn(0, letters.length - 1)].tryRequestFocus() + } + }, ) { items( letters.length, key = { letters[it] }, ) { index -> - val interactionSource = interactionSources[index] + val interactionSource = remember { MutableInteractionSource() } val focused by interactionSource.collectIsFocusedAsState() - - val isCurrentLetter = letters[index] == currentLetter - // Apply alpha to individual items, but keep selected letter fully visible when picker is unfocused - val itemAlpha = - when { - isCurrentLetter && !alphabetPickerFocused -> 1f - alphabetPickerFocused -> .85f - else -> .25f - } - - // Only show circle background for the current letter (or when focused) - // Wrap in Box with clipping to prevent focus indicator from overflowing - Box( + Button( modifier = Modifier - .size(letterButtonSize) - .clip(CircleShape) - .alpha(itemAlpha), + .size(24.dp) + .focusRequester(focusRequesters[index]), + contentPadding = PaddingValues(2.dp), + interactionSource = interactionSource, + onClick = { + letterClicked.invoke(letters[index]) + }, ) { - Button( - modifier = - Modifier - .size(letterButtonSize) - .focusRequester(focusRequesters[index]), - contentPadding = PaddingValues(0.dp), // No padding to maximize text space - interactionSource = interactionSource, - onClick = { - letterClicked.invoke(letters[index]) - }, - colors = - if (isCurrentLetter || focused) { - // Use default button colors for current letter or focused - ButtonDefaults.colors() - } else { - // Transparent background for non-current letters (no circle) - ButtonDefaults.colors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - focusedContainerColor = MaterialTheme.colorScheme.primaryContainer, - focusedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - }, - ) { - // Use border color for selected letter when focused, tertiary for unfocused-selected - val color = - when { - isCurrentLetter && focused -> MaterialTheme.colorScheme.border - isCurrentLetter -> MaterialTheme.colorScheme.tertiary - focused -> LocalContentColor.current - else -> MaterialTheme.colorScheme.onSurface - } - Text( - text = letters[index].toString(), - color = color, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodySmall, - ) - } + val color = + if (!focused && letters[index] == currentLetter) { + MaterialTheme.colorScheme.tertiary + } else { + LocalContentColor.current + } + Text( + text = letters[index].toString(), + color = color, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt new file mode 100644 index 00000000..dc622414 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -0,0 +1,50 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters +import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import java.util.UUID + +@Composable +fun CollectionFolderBoxSet( + preferences: UserPreferences, + itemId: UUID, + item: BaseItem?, + recursive: Boolean, + modifier: Modifier = Modifier, + filter: GetItemsFilter = GetItemsFilter(), + preferencesViewModel: PreferencesViewModel = hiltViewModel(), + playEnabled: Boolean = false, +) { + var showHeader by remember { mutableStateOf(true) } + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, + itemId = itemId, + initialFilter = filter, + showTitle = showHeader, + recursive = recursive, + sortOptions = MovieSortOptions, + modifier = + modifier + .padding(start = 16.dp), + positionCallback = { columns, position -> + showHeader = position < columns + }, + params = CollectionFolderGridParameters.POSTER, + playEnabled = playEnabled, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index ccd7e556..26a669fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -1,23 +1,22 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.components.ViewOptionsWide +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import org.jellyfin.sdk.model.api.ItemSortBy import java.util.UUID @Composable @@ -28,35 +27,34 @@ fun CollectionFolderGeneric( recursive: Boolean, playEnabled: Boolean, modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), + filter: GetItemsFilter = GetItemsFilter(), filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - sortOptions: List<ItemSortBy> = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by rememberSaveable { mutableStateOf(true) } - val viewOptions = + var showHeader by remember { mutableStateOf(true) } + val params = remember(usePosters) { if (usePosters) { - ViewOptionsPoster + CollectionFolderGridParameters.POSTER } else { - ViewOptionsWide + CollectionFolderGridParameters.WIDE } } - CollectionFolderView( + CollectionFolderGrid( preferences = preferences, - onClickItem = { index, item -> - preferencesViewModel.navigationManager.navigateTo(item.destination(index)) - }, + onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, initialFilter = filter, showTitle = showHeader, recursive = recursive, - sortOptions = sortOptions, - modifier = modifier, + sortOptions = VideoSortOptions, + modifier = + modifier + .padding(start = 16.dp), positionCallback = { columns, position -> showHeader = position < columns }, - defaultViewOptions = viewOptions, + params = params, playEnabled = playEnabled, filterOptions = filterOptions, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 18af298b..e6185e45 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,44 +1,52 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.MutableLiveData 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.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.ui.components.CollectionFolderView +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage -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.components.TabRow 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.util.StringStringProvider -import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.RememberTabManager 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 @@ -49,35 +57,23 @@ class LiveTvCollectionViewModel val api: ApiClient, val serverRepository: ServerRepository, val navigationManager: NavigationManager, - val backdropService: BackdropService, - ) : ViewModel() { - val state = MutableStateFlow(LiveTvRecordingFolderState()) + val rememberTabManager: RememberTabManager, + ) : ViewModel(), + RememberTabManager by rememberTabManager { + val recordingFolders = MutableLiveData<List<TabId>>() init { viewModelScope.launchIO { - 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)) - } + val folders = + api.liveTvApi + .getRecordingFolders(userId = serverRepository.currentUser.value?.id) + .content.items + .map { TabId(it.name ?: "Recordings", it.id) } + this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) } } } -data class LiveTvRecordingFolderState( - val folders: DataLoadingState<List<TabId>> = DataLoadingState.Pending, -) - data class TabId( val title: String, val id: UUID, @@ -90,91 +86,93 @@ fun CollectionFolderLiveTv( modifier: Modifier = Modifier, viewModel: LiveTvCollectionViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() + val rememberedTabIndex = + remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } + val folders by viewModel.recordingFolders.observeAsState(listOf()) - when (val st = state.folders) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) + val tabs = + listOf( + TabId(stringResource(R.string.tv_guide), UUID.randomUUID()), + TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), + ) + folders + + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("livetv", selectedTabIndex) + viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + } + 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 = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs.map { it.title }, + onClick = { selectedTabIndex = it }, + ) } + when (selectedTabIndex) { + 0 -> { + TvGuideGrid( + true, + onRowPosition = { + showHeader = it <= 0 + }, + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + 1 -> { + DvrSchedule( + true, + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - 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()) - } - } - - 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 - }, + else -> { + val folderIndex = selectedTabIndex - 2 + if (folderIndex in folders.indices) { + CollectionFolderGrid( + preferences = preferences, + onClickItem = onClickItem, + itemId = folders[folderIndex].id, + initialFilter = GetItemsFilter(), + showTitle = false, + recursive = false, + sortOptions = VideoSortOptions, + modifier = 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) - } - } + .padding(start = 16.dp), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + ) + } else { + ErrorMessage("Invalid tab index $selectedTabIndex", null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index cb1d1812..cfbd6806 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,32 +1,42 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.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 import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.TabDetails -import com.github.damontecres.wholphin.ui.components.TabbedPage -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.components.TabRow 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 com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType @Composable fun CollectionFolderMovie( @@ -35,25 +45,49 @@ fun CollectionFolderMovie( modifier: Modifier = Modifier, preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } + val tabs = - remember { - listOf( - TabDetails(R.string.recommended), - TabDetails(R.string.library), - TabDetails(R.string.collections), - TabDetails(R.string.genres), - ) - } + 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 firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("movie", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + } var showHeader by rememberSaveable { mutableStateOf(true) } - TabbedPage( - itemId = destination.itemId.toString(), - tabs = tabs, + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( modifier = modifier, - showTabs = showHeader, - ) { tabIndex, tabDetails -> - when (tabIndex) { + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + ) + } + when (selectedTabIndex) { // Recommended 0 -> { RecommendedMovie( @@ -64,91 +98,75 @@ fun CollectionFolderMovie( }, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), ) } - // Library 1 -> { - CollectionFolderView( + CollectionFolderGrid( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, - viewModelKey = "${destination.itemId}_library", initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MOVIE), - ), + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MOVIE), ), showTitle = false, recursive = true, sortOptions = MovieSortOptions, - defaultViewOptions = ViewOptionsPoster, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = true, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } - // Collections 2 -> { - CollectionFolderView( + CollectionFolderGrid( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, - viewModelKey = "${destination.itemId}_collection", initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.BOX_SET), - ), + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.BOX_SET), ), showTitle = false, recursive = true, sortOptions = VideoSortOptions, - defaultViewOptions = ViewOptionsPoster, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), positionCallback = { columns, position -> showHeader = position < columns }, playEnabled = false, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } - // Genres 3 -> { GenreCardGrid( itemId = destination.itemId, - includeItemTypes = listOf(BaseItemKind.MOVIE), - collectionType = CollectionType.MOVIES, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), ) } - - else -> { - ErrorMessage("Invalid tab index $tabIndex", null) - } + else -> ErrorMessage("Invalid tab index $selectedTabIndex", 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 deleted file mode 100644 index 9ab7e33f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.compose.foundation.layout.fillMaxSize -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.compose.ui.focus.focusRequester -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.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 -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.GridClickActions -import com.github.damontecres.wholphin.ui.components.RecommendedMusic -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.nav.Destination -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import 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 - -@HiltViewModel(assistedFactory = CollectionFolderMusicViewModel.Factory::class) -class CollectionFolderMusicViewModel - @AssistedInject - constructor( - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val musicService: MusicService, - private val navigationManager: NavigationManager, - val backdropService: BackdropService, - @Assisted private val itemId: UUID, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(itemId: UUID): CollectionFolderMusicViewModel - } - - fun play(item: BaseItem) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } - } - - fun onClick( - index: Int, - item: BaseItem, - ) { - navigationManager.navigateTo(item.destination()) - } - - fun onClickPlayAll(shuffle: Boolean) { - viewModelScope.launchDefault { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - includeItemTypes = listOf(BaseItemKind.AUDIO), - recursive = true, - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() - musicService.setQueue(pager, 0, shuffle) - } - } - - fun onClickPlayRemoteButton( - index: Int, - item: BaseItem, - ) { - if (item.type == BaseItemKind.AUDIO) { - viewModelScope.launchDefault { - musicService.setQueue(listOf(item), false) - } - } - } - } - -@Composable -fun CollectionFolderMusic( - preferences: UserPreferences, - destination: Destination.MediaItem, - modifier: Modifier = Modifier, - viewModel: CollectionFolderMusicViewModel = - hiltViewModel<CollectionFolderMusicViewModel, CollectionFolderMusicViewModel.Factory>( - creationCallback = { it.create(destination.itemId) }, - ), -) { - val tabs = - 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 = - remember { - GridClickActions( - onClickItem = viewModel::onClick, - onLongClickItem = null, - onClickPlayAll = viewModel::onClickPlayAll, - onClickPlayRemoteButton = viewModel::onClickPlayRemoteButton, - ) - } - TabbedPage( - itemId = destination.itemId.toString(), - tabs = tabs, - modifier = modifier, - showTabs = showHeader, - ) { tabIndex, tabDetails -> - when (tabIndex) { - // Recommended - 0 -> { - RecommendedMusic( - preferences = preferences, - parentId = destination.itemId, - onFocusPosition = { pos -> - showHeader = pos.row < 1 - }, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - ) - } - - // Albums - 1 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = destination.itemId, - viewModelKey = "${destination.itemId}_albums", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - ), - ), - showTitle = false, - recursive = true, - sortOptions = AlbumSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = true, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // Artists - 2 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = destination.itemId, - viewModelKey = "${destination.itemId}_artists", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - override = GetItemsFilterOverride.ARTIST, - ), - ), - showTitle = false, - recursive = true, - sortOptions = ArtistSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = false, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - // Genres - 3 -> { - GenreCardGrid( - itemId = destination.itemId, - includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), - collectionType = CollectionType.MUSIC, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - ) - } - - // Songs - 4 -> { - CollectionFolderView( - preferences = preferences, - actions = actions, - itemId = destination.itemId, - viewModelKey = "${destination.itemId}_songs", - initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.AUDIO), - ), - ), - showTitle = false, - recursive = true, - sortOptions = SongSortOptions, - defaultViewOptions = ViewOptionsSquare, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - positionCallback = { columns, position -> - showHeader = position < columns - }, - playEnabled = true, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - ) - } - - else -> { - 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 deleted file mode 100644 index 02ecfd9f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView -import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel -import com.github.damontecres.wholphin.ui.components.GridClickActions -import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.toServerString -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import java.util.UUID - -@Composable -fun CollectionFolderPhotoAlbum( - preferences: UserPreferences, - itemId: UUID, - recursive: Boolean, - modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), - filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, - sortOptions: List<ItemSortBy> = VideoSortOptions, - // Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items - // TODO refactor this - viewModel: CollectionFolderViewModel = - hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( - key = itemId.toServerString(), - ) { - it.create( - itemId = itemId.toServerString(), - initialSortAndDirection = null, - recursive = recursive, - collectionFilter = filter, - useSeriesForPrimary = false, - defaultViewOptions = ViewOptionsWide, - ) - }, -) { - var showHeader by rememberSaveable { mutableStateOf(true) } - val state by viewModel.state.collectAsState() - CollectionFolderView( - preferences = preferences, - actions = - remember { - GridClickActions( - onClickItem = { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO) { - Destination.Slideshow( - parentId = itemId, - index = index, - filter = - CollectionFolderFilter( - filter = state.filter, - ), - sortAndDirection = state.sortAndDirection, - recursive = recursive, - startSlideshow = false, - ) - } else { - item.destination(index) - } - viewModel.navigateTo(destination) - }, - ) - }, - itemId = itemId.toServerString(), - initialFilter = filter, - showTitle = showHeader, - recursive = recursive, - sortOptions = sortOptions, - modifier = modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - defaultViewOptions = ViewOptionsWide, - playEnabled = true, - filterOptions = filterOptions, - viewModel = viewModel, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 70acd69b..c630a85b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -1,16 +1,19 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView -import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import java.util.UUID @@ -19,13 +22,14 @@ import java.util.UUID fun CollectionFolderPlaylist( preferences: UserPreferences, itemId: UUID, + item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), + filter: GetItemsFilter = GetItemsFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by rememberSaveable { mutableStateOf(true) } - CollectionFolderView( + var showHeader by remember { mutableStateOf(true) } + CollectionFolderGrid( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, @@ -33,11 +37,13 @@ fun CollectionFolderPlaylist( showTitle = showHeader, recursive = recursive, sortOptions = PlaylistSortOptions, - modifier = modifier, + modifier = + modifier + .padding(start = 16.dp), positionCallback = { columns, position -> showHeader = position < columns }, - defaultViewOptions = ViewOptionsSquare, + params = CollectionFolderGridParameters.SQUARE, playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 95fcaf00..c86088e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -1,16 +1,18 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderView -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import java.util.UUID @@ -21,11 +23,11 @@ fun CollectionFolderRecordings( itemId: UUID, recursive: Boolean, modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), + filter: GetItemsFilter = GetItemsFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - var showHeader by rememberSaveable { mutableStateOf(true) } - CollectionFolderView( + var showHeader by remember { mutableStateOf(true) } + CollectionFolderGrid( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, @@ -33,11 +35,13 @@ fun CollectionFolderRecordings( showTitle = showHeader, recursive = recursive, sortOptions = MovieSortOptions, - modifier = modifier, + modifier = + modifier + .padding(start = 16.dp), positionCallback = { columns, position -> showHeader = position < columns }, - defaultViewOptions = ViewOptionsPoster, + params = CollectionFolderGridParameters.POSTER, playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 2f8fb909..75033bde 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,77 +1,96 @@ package com.github.damontecres.wholphin.ui.detail +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.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.CollectionFolderGrid 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.TabDetails -import com.github.damontecres.wholphin.ui.components.TabbedPage -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.SeriesSortOptions +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import dagger.hilt.android.lifecycle.HiltViewModel +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus 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, - viewModel: CollectionFolderTvViewModel = hiltViewModel(), + preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, destination.itemId, 0) } + val tabs = - 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()) - } - } + listOf( + stringResource(R.string.recommended), + stringResource(R.string.library), + stringResource(R.string.genres), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("tv", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) + } + + val onClickItem = { item: BaseItem -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + } var showHeader by rememberSaveable { mutableStateOf(true) } - TabbedPage( - itemId = destination.itemId.toString(), - tabs = tabs, + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( modifier = modifier, - showTabs = showHeader, - ) { tabIndex, tabDetails -> - when (tabIndex) { + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + ) + } + when (selectedTabIndex) { // Recommended 0 -> { RecommendedTvShow( @@ -82,69 +101,49 @@ fun CollectionFolderTv( }, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), ) } - // Library 1 -> { - CollectionFolderView( + CollectionFolderGrid( preferences = preferences, itemId = destination.itemId, initialFilter = - CollectionFolderFilter( - filter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.SERIES), - ), + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.SERIES), ), showTitle = false, recursive = true, sortOptions = SeriesSortOptions, - filterOptions = DefaultTvFilterOptions, - defaultViewOptions = ViewOptionsPoster, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = onClickItem, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, playEnabled = false, - focusRequesterOnEmpty = tabDetails.tabFocusRequester, ) } - // Genres 2 -> { GenreCardGrid( itemId = destination.itemId, - includeItemTypes = listOf(BaseItemKind.SERIES), - collectionType = CollectionType.TVSHOWS, modifier = Modifier + .padding(start = 16.dp) .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), + .focusRequester(focusRequester), ) } - - // Studios - 3 -> { - StudioCardGrid( - itemId = destination.itemId, - includeItemTypes = listOf(BaseItemKind.SERIES), - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - ) - } - - else -> { - ErrorMessage("Invalid tab index $tabIndex", null) - } + else -> ErrorMessage("Invalid tab index $selectedTabIndex", 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 fb4cdbdd..5f771ab1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -1,34 +1,20 @@ 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 -import android.view.Display.HdrCapabilities import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalGridApi -import androidx.compose.foundation.layout.Grid -import androidx.compose.foundation.layout.GridFlow -import androidx.compose.foundation.layout.GridTrackSize 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.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -37,252 +23,122 @@ 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.text.font.FontWeight +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel 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.data.ItemPlaybackDao -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.DeviceProfileService -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.Dispatchers 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 -import java.util.Locale import javax.inject.Inject @HiltViewModel class DebugViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - val seriesTrackChoiceDao: SeriesTrackChoiceDao, - val clientInfo: ClientInfo, - val deviceInfo: DeviceInfo, - private val deviceProfileService: DeviceProfileService, ) : ViewModel() { - val state = MutableStateFlow(DebugState()) - val itemPlaybacks = MutableStateFlow<List<ItemPlayback>>(emptyList()) - val logcat = MutableStateFlow<List<LogcatLine>>(emptyList()) - - val display: Display by lazy { - val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - displayManager.getDisplay(Display.DEFAULT_DISPLAY) - } - - val av1Included by lazy { - try { - Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") - true - } catch (_: ClassNotFoundException) { - false - } - } - - val ffmpegIncluded by lazy { - try { - Class.forName("androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer") - true - } catch (_: ClassNotFoundException) { - false - } - } - - val libMpvLoaded by lazy { - try { - System.loadLibrary("player") - System.loadLibrary("mpv") - true - } catch (_: Exception) { - false - } - } + val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf()) + val logcat = MutableLiveData<List<LogcatLine>>(listOf()) 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 = display.supportedModes.map { it.toDisplayMode(display) }, - ) - } - } viewModelScope.launchIO { - serverRepository.currentUser?.rowId?.let { + serverRepository.currentUser.value?.rowId?.let { val results = itemPlaybackDao.getItems(it) - itemPlaybacks.value = results + withContext(Dispatchers.Main) { + itemPlaybacks.value = results + } } - } - viewModelScope.launchIO { - serverRepository.currentUser?.rowId?.let { - val results = seriesTrackChoiceDao.getAll(it) - Timber.v("Got %s SeriesTrackChoice", results.size) - results.forEach { Timber.v("%s", it) } - } - } - viewModelScope.launchIO { 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) } - } - viewModelScope.launchIO { - val hdrSupport = - buildMap { - put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10()) - put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus()) - put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision()) - put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL()) - put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8()) - put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10()) - put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus()) - put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision()) - } - state.update { it.copy(hdrSupport = hdrSupport) } + withContext(Dispatchers.Main) { + this@DebugViewModel.logcat.value = logcat + } } } companion object { - 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") - } - 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() + 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") } - return@withContext logLines + 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() } + 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 + + """.trimIndent() + logcat + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + } } } @@ -291,160 +147,30 @@ data class LogcatLine( val text: String, ) -data class DebugState( - val appInfo: List<String> = emptyList(), - val deviceInfo: List<String> = emptyList(), - val displayModes: List<DisplayMode> = emptyList(), - val audioInfo: List<String> = emptyList(), - val hdrSupport: Map<String, Boolean> = emptyMap(), -) - -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)" - } - -@OptIn(ExperimentalGridApi::class) @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, - scrollAmount: Float = 100f, - ) { + fun scroll(reverse: Boolean = false) { scope.launch(ExceptionHandler()) { columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount) } } - val itemPlaybacks by viewModel.itemPlaybacks.collectAsState() - val logcat by viewModel.logcat.collectAsState() - - val padding = remember { PaddingValues(bottom = 32.dp) } + val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf()) + val logcat by viewModel.logcat.observeAsState(listOf()) LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), modifier = modifier .focusable() @@ -462,133 +188,48 @@ 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() - .padding(padding), + modifier = Modifier.fillMaxWidth(), ) { - SectionTitle("App Information") - state.appInfo.forEach { - BodyText(it) - } - } - } - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .padding(padding), - ) { - SectionTitle("Device Information") - state.deviceInfo.forEach { - BodyText(it) - } - - SubSectionTitle("Audio Devices") - state.audioInfo.forEach { - BodyText(it) - } - - SubSectionTitle("Display Modes") - Grid( - config = { - repeat(6) { column(GridTrackSize.Auto) } - flow = GridFlow.Row - columnGap(8.dp) - rowGap(8.dp) - }, - ) { - EmphasizedBodyText("ID") - EmphasizedBodyText("Width") - EmphasizedBodyText("Height") - EmphasizedBodyText("FPS") - EmphasizedBodyText("HDR") - EmphasizedBodyText("Alt FPS") - - state.displayModes.forEachIndexed { index, mode -> - BodyText(mode.id.toString()) - BodyText(mode.width.toString()) - BodyText(mode.height.toString()) - BodyText(mode.fps) - BodyText(mode.hdr) - BodyText(mode.altFps) + Text( + text = "App Information", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + 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)) } - } - - SubSectionTitle("Detected HDR Support") - Grid( - config = { - column(GridTrackSize.Auto) - column(GridTrackSize.Auto) - flow = GridFlow.Row - columnGap(8.dp) - rowGap(4.dp) - }, - ) { - EmphasizedBodyText("HDR Format") - EmphasizedBodyText("Supported") - state.hdrSupport.forEach { (name, supported) -> - BodyText(name) - BodyText(supported.toString()) - } - } - } - } - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .padding(padding), - ) { - SectionTitle("AppPreferences") - BodyText(preferences.appPreferences.toString()) - } - } - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .padding(padding), - ) { - 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() - .padding(padding), - ) { - SectionTitle("Database") - - SubSectionTitle("ItemPlayback") - itemPlaybacks.forEach { - BodyText(it.toString()) + ( + listOf( + "Version Name: ${pkgInfo.versionName}", + "Version Code: ${pkgInfo.versionCodeLong}", + "Build type: ${BuildConfig.BUILD_TYPE}", + "Debug enabled: ${BuildConfig.DEBUG}", + "ABIs: ${Build.SUPPORTED_ABIS.toList()}", + ) + installInfo + ).forEach { + Text( + text = it.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) } } } @@ -597,73 +238,100 @@ fun DebugPage( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - SectionTitle("Logcat") + Text( + text = "AppPreferences", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = preferences.appPreferences.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) } } - 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 + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "User Information", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Current server: ${viewModel.serverRepository.currentServer.value}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Current user: ${viewModel.serverRepository.currentUser.value}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "User server settings: ${preferences.userConfig}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Database", + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "ItemPlayback", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + itemPlaybacks.forEach { + Text( + text = it.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) } - Text( - text = line, - style = MaterialTheme.typography.bodySmall, - color = color, - ) + } + } + item { + Column( + 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, + ) + } + } } } } -@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, - modifier: Modifier = Modifier, -) { - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = modifier, - ) -} - -@Composable -private fun EmphasizedBodyText( - text: String, - modifier: Modifier = Modifier, -) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - modifier = modifier, - ) -} - private val THIRD_PARTY_TAGS = listOf( "libc:F", @@ -692,45 +360,3 @@ private val THIRD_PARTY_TAGS = "HardwareConfig:W", "DfltImageHeaderParser:W", ) - -data class DisplayMode( - val id: Int, - val width: Int, - val height: Int, - val fps: String, - val hdr: String, - val altFps: String, -) - -fun Display.Mode.toDisplayMode(display: Display): DisplayMode { - val fps = String.format(Locale.getDefault(), "%.3f", refreshRate) - val altFps = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - alternativeRefreshRates.joinToString(", ") { - String.format(Locale.getDefault(), "%.3f", it) - } - } else { - "N/A" - } - val hdr = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) } - ?: "None found" - } else { - "N/A" - } - return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps) -} - -fun hdrTypeToString(type: Int): String = - when (type) { - HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10" - HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+" - HdrCapabilities.HDR_TYPE_HLG -> "HLG" - HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+" - HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi" - HdrCapabilities.HDR_TYPE_INVALID -> "Invalid" - else -> "Unknown ($type)" - } 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 new file mode 100644 index 00000000..d62d0f73 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -0,0 +1,312 @@ +package com.github.damontecres.wholphin.ui.detail + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.ArrowForward +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.data.model.Person +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.letNotEmpty +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 org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +data class MoreDialogActions( + val navigateTo: (Destination) -> Unit, + var onClickWatch: (UUID, Boolean) -> Unit, + var onClickFavorite: (UUID, Boolean) -> Unit, + var onClickAddPlaylist: (UUID) -> Unit, +) + +/** + * Build the [DialogItem]s when clicking "More" + * + * If there are multiple versions, adds an option to pick one + * + * If there is more than one (ie two or more) audio track, adds an option to pick one + * + * If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one + * + * @param item the media item to build for, typically an Episode or Movie + * @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option + * @param sourceId the item's media source UUID + * @param navigateTo a function to trigger a navigation + * @param onChooseVersion callback to pick a version of the item + * @param onChooseTracks callback to pick a track for the given type of the item + */ +fun buildMoreDialogItems( + context: Context, + item: BaseItem, + series: BaseItem?, + sourceId: UUID?, + watched: Boolean, + favorite: Boolean, + actions: MoreDialogActions, + onChooseVersion: () -> Unit, + onChooseTracks: (MediaStreamType) -> Unit, +): List<DialogItem> = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.navigateTo( + Destination.Playback( + item.id, + item.resumeMs ?: 0L, + item, + ), + ) + }, + ) + item.data.mediaSources?.letNotEmpty { sources -> + val source = + sourceId?.let { sources.firstOrNull { it.id?.toUUIDOrNull() == sourceId } } + ?: sources.firstOrNull() + source?.mediaStreams?.letNotEmpty { streams -> + val audioCount = streams.count { it.type == MediaStreamType.AUDIO } + val subtitleCount = streams.count { it.type == MediaStreamType.SUBTITLE } + if (audioCount > 1) { + add( + DialogItem( + context.getString( + R.string.choose_stream, + context.getString(R.string.audio), + ), + R.string.fa_volume_low, + ) { + onChooseTracks.invoke(MediaStreamType.AUDIO) + }, + ) + } + if (subtitleCount > 0) { + add( + DialogItem( + context.getString( + R.string.choose_stream, + context.getString(R.string.subtitles), + ), + R.string.fa_closed_captioning, + ) { + onChooseTracks.invoke(MediaStreamType.SUBTITLE) + }, + ) + } + } + if (sources.size > 1) { + add( + DialogItem( + context.getString( + R.string.choose_stream, + context.getString(R.string.version), + ), + R.string.fa_file_video, + ) { + onChooseVersion.invoke() + }, + ) + } + } + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + 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(item.id, !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(item.id, !favorite) + }, + ) + series?.let { + add( + DialogItem( + context.getString(R.string.go_to_series), + Icons.AutoMirrored.Filled.ArrowForward, + ) { + actions.navigateTo( + Destination.MediaItem( + series.id, + BaseItemKind.SERIES, + series, + ), + ) + }, + ) + } + add( + DialogItem( + context.getString(R.string.play_with_transcoding), + Icons.Default.PlayArrow, + ) { + actions.navigateTo( + Destination.Playback( + item.id, + item.resumeMs ?: 0L, + item, + forceTranscoding = true, + ), + ) + }, + ) + } + +fun buildMoreDialogItemsForHome( + context: Context, + item: BaseItem, + seriesId: UUID?, + playbackPosition: Duration, + watched: Boolean, + favorite: Boolean, + actions: MoreDialogActions, +): List<DialogItem> = + buildList { + val itemId = item.id + add( + DialogItem( + context.getString(R.string.go_to), + Icons.Default.ArrowForward, + ) { + actions.navigateTo(item.destination()) + }, + ) + if (item.type in supportedPlayableTypes) { + if (playbackPosition >= 1.seconds) { + add( + DialogItem( + context.getString(R.string.resume), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.navigateTo( + Destination.Playback( + itemId, + playbackPosition.inWholeMilliseconds, + ), + ) + }, + ) + add( + DialogItem( + context.getString(R.string.restart), + Icons.Default.Refresh, +// iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.navigateTo( + Destination.Playback( + itemId, + 0L, + ), + ) + }, + ) + } else { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + actions.navigateTo( + Destination.Playback( + itemId, + 0L, + ), + ) + }, + ) + } + } + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(itemId) + }, + ) + 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( + context.getString(R.string.go_to_series), + Icons.AutoMirrored.Filled.ArrowForward, + ) { + actions.navigateTo( + Destination.MediaItem( + it, + BaseItemKind.SERIES, + ), + ) + }, + ) + } + } + +fun buildMoreDialogItemsForPerson( + context: Context, + person: Person, + actions: MoreDialogActions, +): List<DialogItem> = + buildList { + val itemId = person.id + add( + DialogItem( + context.getString(R.string.go_to), + Icons.Default.ArrowForward, + ) { + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) + }, + ) + add( + DialogItem( + text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + iconColor = if (person.favorite) Color.Red else Color.Unspecified, + ) { + actions.onClickFavorite.invoke(itemId, !person.favorite) + }, + ) + } 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 0e46999f..a68ce42d 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,130 +1,278 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.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.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.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.BaseItem +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.OneTimeLaunchedEffect -import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.GridClickActions -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.components.TabRow +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.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, - viewModel: FavoritesViewModel = hiltViewModel(), + preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { - 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 uiPrefs = preferences.appPreferences.interfacePreferences + val rememberedTabIndex = + remember { + preferencesViewModel.getRememberedTab( + preferences, + NavDrawerItem.Favorites.id, + 0, + ) } - FavoritesLoadingState.Loading, - FavoritesLoadingState.Pending, - -> { - LoadingPage(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), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } - FavoritesLoadingState.NoFavorites -> { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Box( - contentAlignment = Alignment.Center, + LaunchedEffect(selectedTabIndex) { + logTab("favorites", selectedTabIndex) + preferencesViewModel.saveRememberedTab( + preferences, + NavDrawerItem.Favorites.id, + selectedTabIndex, + ) + } + 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 = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, modifier = - modifier - .focusRequester(focusRequester) - .focusable(), - ) { - Text( - text = stringResource(R.string.no_favorites_found), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier, + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), + tabs = tabs, + onClick = { selectedTabIndex = it }, + ) + } + // TODO playEnabled = true for movies & episodes + when (selectedTabIndex) { + // Movies + 0 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_movies", + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), + showTitle = false, + recursive = true, + sortOptions = MovieSortOptions, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, ) } - } - - 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, - ) - - contextMenu.Compose() - } else { - ErrorMessage("Invalid tab $type", null) - } + // TV + 1 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_series", + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.SERIES), + ), + showTitle = false, + recursive = true, + sortOptions = SeriesSortOptions, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, + ) } + // Episodes + 2 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_episodes", + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + ), + showTitle = false, + recursive = true, + sortOptions = EpisodeSortOptions, + params = CollectionFolderGridParameters.WIDE, + useSeriesForPrimary = false, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, + ) + } + // Videos + 3 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_videos", + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.VIDEO), + ), + showTitle = false, + recursive = true, + sortOptions = VideoSortOptions, + params = CollectionFolderGridParameters.WIDE, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, + ) + } + // Playlists + 4 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_playlists", + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.PLAYLIST), + ), + showTitle = false, + recursive = true, + sortOptions = VideoSortOptions, + params = CollectionFolderGridParameters.SQUARE, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = DefaultForFavoritesFilterOptions, + ) + } + // People + 5 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = { _, item -> onClickItem.invoke(item) }, + itemId = "${NavDrawerItem.Favorites.id}_people", + initialFilter = + GetItemsFilter( + favorite = true, + override = GetItemsFilterOverride.PERSON, + ), + initialSortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + showTitle = false, + recursive = true, + sortOptions = listOf(), + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + playEnabled = false, + filterOptions = listOf(), + ) + } + else -> ErrorMessage("Invalid tab index $selectedTabIndex", 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 deleted file mode 100644 index 5690fadc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ /dev/null @@ -1,813 +0,0 @@ -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.ui.util.goToRandom -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 list = - collectionState.items.successValue as? BlockingList<BaseItem?> - if (list != null) { - goToRandom( - navigateTo = ::navigateTo, - parentId = libraryDisplayItemId(type), - filter = - collectionState.filter.copy( - includeItemTypes = listOf(type), - ), - sortAndDirection = collectionState.sortAndDirection, - recursive = true, - list = list, - ) - } else { - Timber.w( - "state.value.items is not successful: %s", - collectionState.items, - ) - } - } - } 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 deleted file mode 100644 index 4e49bd39..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ /dev/null @@ -1,322 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import android.content.Context -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomeRowConfig -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.HomeDataService -import com.github.damontecres.wholphin.services.MediaManagementService -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.ui.cards.GridCard -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.StudioCardGrid -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.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.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber -import java.util.UUID - -@HiltViewModel(assistedFactory = HomeRowGridViewModel.Factory::class) -class HomeRowGridViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, - private val navDrawerService: NavDrawerService, - private val homeDataService: HomeDataService, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - private val navigationManager: NavigationManager, - private val themeSongPlayer: ThemeSongPlayer, - private val mediaManagementService: MediaManagementService, - private val musicService: MusicService, - val streamChoiceService: StreamChoiceService, - val serverReportService: ServerReportService, - @Assisted private val title: StringProvider, - @Assisted private val rowConfig: HomeRowConfig, - ) : ViewModel(), - ContextMenuProvider { - @AssistedFactory - interface Factory { - fun create( - title: StringProvider, - rowConfig: HomeRowConfig, - ): HomeRowGridViewModel - } - - private val _state = MutableStateFlow(HomeRowGridState()) - val state: StateFlow<HomeRowGridState> = _state - - init { - viewModelScope.launchDefault { - when (rowConfig) { - is HomeRowConfig.Genres, - is HomeRowConfig.Studios, - -> { - // Genres & Studios will be looked up by another ViewModel, so just return - _state.update { - it.copy( - loading = - HomeRowLoadingState.Success( - title, - emptyList(), - rowConfig.viewOptions, - ), - ) - } - return@launchDefault - } - - else -> {} - } - try { - val preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - serverRepository.currentUserDto?.let { userDto -> - val libraries = - navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) - val result = - homeDataService.fetchDataForRow( - row = rowConfig, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = libraries, - isRefresh = true, - limit = 1_000, // TODO - usePaging = true, - ) - Timber.v( - "Got %s items for %s", - (result as? HomeRowLoadingState.Success)?.items?.size, - rowConfig, - ) - _state.update { it.copy(loading = result) } - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching: %s", rowConfig) - _state.update { - it.copy( - loading = - HomeRowLoadingState.Error( - title, - null, - ex, - ), - ) - } - } - } - } - - override fun setWatched( - position: Int, - itemId: UUID, - played: Boolean, - ) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (state.value.loading as? HomeRowLoadingState.Success)?.let { - (it.items 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.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) - } - } - - 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 { - // TODO refresh - } - } - } - } - -data class HomeRowGridState( - val loading: HomeRowLoadingState = HomeRowLoadingState.Pending(StringStringProvider("")), -) - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun HomeRowGrid( - preferences: UserPreferences, - destination: Destination.MoreHomeRow, - modifier: Modifier = Modifier, - viewModel: HomeRowGridViewModel = - hiltViewModel<HomeRowGridViewModel, HomeRowGridViewModel.Factory>( - creationCallback = { it.create(destination.title, destination.config) }, - ), -) { - val state by viewModel.state.collectAsState() - val contextMenu = rememberContextMenu(preferences, viewModel) - val gridFocusRequester = remember { FocusRequester() } - val viewOptions = destination.config.viewOptions - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - GridTitle(destination.title.getString()) - - when (val st = state.loading) { - is HomeRowLoadingState.Error -> { - ErrorMessage(st.message, st.exception, Modifier.fillMaxSize()) - } - - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - LoadingPage(Modifier.fillMaxSize()) - } - - is HomeRowLoadingState.Success -> { - when (destination.config) { - is HomeRowConfig.Genres -> { - GenreCardGrid( - itemId = destination.config.parentId, - includeItemTypes = null, - modifier = Modifier.fillMaxSize(), - initialPosition = destination.initialPosition, - collectionType = CollectionType.UNKNOWN, - ) - } - - is HomeRowConfig.Studios -> { - StudioCardGrid( - itemId = destination.config.parentId, - includeItemTypes = null, - modifier = Modifier.fillMaxSize(), - initialPosition = destination.initialPosition, - ) - } - - else -> { - val onClickItem = - remember { - { index: Int, item: BaseItem -> - viewModel.navigateTo(item.destination(index)) - } - } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - CardGrid( - pager = st.items, - onClickItem = onClickItem, - onLongClickItem = contextMenu::showContextMenu, - onClickPlay = { _, _ -> }, - letterPosition = { -1 }, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, - showLetterButtons = false, - modifier = Modifier.fillMaxSize(), - initialPosition = destination.initialPosition, - positionCallback = { columns, newPosition -> - }, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = true, // viewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) - }, - columns = if (viewOptions.aspectRatio.ratio > 1f) 4 else 6, - spacing = viewOptions.spacing.dp, - bringIntoViewSpec = LocalBringIntoViewSpec.current, - ) - } - } - } - } - } - contextMenu.Compose() -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt new file mode 100644 index 00000000..d917b47a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt @@ -0,0 +1,91 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber +import java.util.UUID + +/** + * Basic [ViewModel] for a single fetchable item from the API + */ +abstract class ItemViewModel( + val api: ApiClient, +) : ViewModel() { + val item = MutableLiveData<BaseItem?>(null) + lateinit var itemId: String + lateinit var itemUuid: UUID + + suspend fun fetchItem(itemId: UUID): BaseItem = + withContext(Dispatchers.IO) { + this@ItemViewModel.itemId = itemId.toServerString() + this@ItemViewModel.itemUuid = itemId + val it = api.userLibraryApi.getItem(itemId).content + val fetchedItem = BaseItem.from(it, api) + return@withContext fetchedItem.let { + withContext(Dispatchers.Main) { + item.value = fetchedItem + } + fetchedItem + } + } + + fun imageUrl( + itemId: UUID, + type: ImageType, + ): String? = api.imageApi.getItemImageUrl(itemId, type) +} + +/** + * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred + */ +abstract class LoadingItemViewModel( + api: ApiClient, +) : ItemViewModel(api) { + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + + open fun init( + itemId: UUID, + potential: BaseItem?, + ): Deferred<BaseItem?> = + viewModelScope.async( + LoadingExceptionHandler( + loading, + "Error loading item $itemId", + ) + Dispatchers.IO, + ) { + loading.setValueOnMain(LoadingState.Loading) + try { + fetchAndSetItem(itemId) + } catch (e: Exception) { + Timber.e(e, "Failed to load item $itemId") + withContext(Dispatchers.Main) { + item.value = null + loading.value = LoadingState.Error("Error loading item $itemId", e) + } + null + } + } + + open suspend fun fetchAndSetItem(itemId: UUID): BaseItem = + withContext(Dispatchers.IO) { + val item = fetchItem(itemId) + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + return@withContext item + } +} 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 64c48f0e..5a33edcb 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 @@ -16,8 +16,8 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -28,12 +28,11 @@ 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 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -41,17 +40,12 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.DiscoverItem 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.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -59,111 +53,66 @@ import com.github.damontecres.wholphin.ui.components.LoadingRow import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.discover.DiscoverRow -import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.formatDate -import com.github.damontecres.wholphin.ui.ifElse 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.rememberPosition +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.DataLoadingState -import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState -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.update 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.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.time.LocalDate import java.util.UUID +import javax.inject.Inject -@HiltViewModel(assistedFactory = PersonViewModel.Factory::class) +@HiltViewModel class PersonViewModel - @AssistedInject + @Inject constructor( - private val api: ApiClient, + api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, - private val seerrService: SeerrService, - @Assisted val itemId: UUID, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(itemId: UUID): PersonViewModel - } + ) : LoadingItemViewModel(api) { + val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) + val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) + val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) - private val _state = MutableStateFlow(PersonState()) - val state: StateFlow<PersonState> = _state - - private suspend fun updatePerson(): BaseItem { - val person = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it) } - _state.update { - it.copy( - person = DataLoadingState.Success(person), - ) - } - return person - } - - init { - viewModelScope.launchIO { - try { - val person = updatePerson() + fun init(itemId: UUID) { + viewModelScope.launchIO( + LoadingExceptionHandler( + loading, + "Error loading item $itemId", + ), + ) { + super.init(itemId, null).await()?.let { person -> val dto = person.data if ((dto.movieCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.MOVIE) { state, row -> - state.copy(movies = row) - } + fetchRow(person.id, BaseItemKind.MOVIE, movies) } else { - _state.update { it.copy(movies = RowLoadingState.Success(emptyList())) } + movies.setValueOnMain(RowLoadingState.Success(listOf())) } if ((dto.seriesCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.SERIES) { state, row -> - state.copy(series = row) - } + fetchRow(person.id, BaseItemKind.SERIES, series) } else { - _state.update { it.copy(series = RowLoadingState.Success(emptyList())) } + series.setValueOnMain(RowLoadingState.Success(listOf())) } if ((dto.episodeCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.EPISODE) { state, row -> - state.copy(episodes = row) - } + fetchRow(person.id, BaseItemKind.EPISODE, episodes) } else { - _state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) } + episodes.setValueOnMain(RowLoadingState.Success(listOf())) } - viewModelScope.launchIO { - val results = seerrService.similar(person).orEmpty() - _state.update { - it.copy( - discovered = results, - discoverPerson = seerrService.discoverPerson(person), - ) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching person %s", itemId) - _state.update { it.copy(person = DataLoadingState.Error(ex)) } } } } @@ -171,18 +120,16 @@ class PersonViewModel private fun fetchRow( itemId: UUID, type: BaseItemKind, - update: (PersonState, RowLoadingState) -> PersonState, + target: MutableLiveData<RowLoadingState>, ) { viewModelScope.launchIO { - _state.update { - update.invoke(it, RowLoadingState.Loading) - } + target.setValueOnMain(RowLoadingState.Loading) try { val request = GetItemsRequest( personIds = listOf(itemId), includeItemTypes = listOf(type), - fields = ItemRowFields, + fields = SlimItemFields, recursive = true, sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME), sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING), @@ -196,121 +143,81 @@ class PersonViewModel pageSize = 15, useSeriesForPrimary = false, ).init() - _state.update { - update.invoke(it, RowLoadingState.Success(pager)) - } + target.setValueOnMain(RowLoadingState.Success(pager)) } catch (ex: Exception) { Timber.e(ex, "Error fetching $type for $itemId") - _state.update { - update.invoke(it, RowLoadingState.Error(ex)) - } + target.setValueOnMain(RowLoadingState.Error(ex)) } } } fun setFavorite(favorite: Boolean) { viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemId, favorite) - updatePerson() + favoriteWatchManager.setFavorite(itemUuid, favorite) + fetchAndSetItem(itemUuid) } } } -data class PersonState( - val person: DataLoadingState<BaseItem> = DataLoadingState.Pending, - val movies: RowLoadingState = RowLoadingState.Pending, - val series: RowLoadingState = RowLoadingState.Pending, - val episodes: RowLoadingState = RowLoadingState.Pending, - val discovered: List<DiscoverItem> = emptyList(), - val discoverPerson: DiscoverItem? = null, -) - @Composable fun PersonPage( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PersonViewModel = - hiltViewModel<PersonViewModel, PersonViewModel.Factory>( - creationCallback = { it.create(destination.itemId) }, - ), + viewModel: PersonViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() - when (val st = state.person) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } + OneTimeLaunchedEffect { + viewModel.init(destination.itemId) + } + val person by viewModel.item.observeAsState() + val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) + val series by viewModel.series.observeAsState(RowLoadingState.Pending) + val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage(modifier) - is DataLoadingState.Success<BaseItem> -> { - val person = st.data - var showOverviewDialog by remember { mutableStateOf(false) } - val name = remember(person) { person.name ?: person.id.toString() } - val imageUrlService = LocalImageUrlService.current - val imageUrl = - remember { - imageUrlService.getItemImageUrl( - itemId = person.id, - imageType = ImageType.PRIMARY, - tag = person.data.imageTags?.get(ImageType.PRIMARY), + LoadingState.Success -> { + person?.let { person -> + var showOverviewDialog by remember { mutableStateOf(false) } + val name = person.name ?: person.id.toString() + PersonPageContent( + preferences = preferences, + name = name, + overview = person.data.overview, + imageUrl = person.imageUrl, + birthdate = person.data.premiereDate?.toLocalDate(), + deathdate = person.data.endDate?.toLocalDate(), + birthPlace = person.data.productionLocations?.firstOrNull(), + favorite = person.favorite, + movies = movies, + series = series, + episodes = episodes, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + overviewOnClick = { showOverviewDialog = true }, + favoriteOnClick = { + viewModel.setFavorite(!person.favorite) + }, + modifier = modifier, + ) + AnimatedVisibility(showOverviewDialog) { + ItemDetailsDialog( + info = + ItemDetailsDialogInfo( + title = name, + overview = person.data.overview, + files = listOf(), + ), + showFilePath = false, + onDismissRequest = { showOverviewDialog = false }, ) } - // 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, - overview = person.data.overview, - imageUrl = imageUrl, - birthdate = person.data.premiereDate?.toLocalDate(), - deathdate = person.data.endDate?.toLocalDate(), - birthPlace = person.data.productionLocations?.firstOrNull(), - favorite = person.favorite, - movies = state.movies, - series = state.series, - episodes = state.episodes, - onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(item.destination()) - }, - overviewOnClick = { showOverviewDialog = true }, - favoriteOnClick = { - viewModel.setFavorite(!person.favorite) - }, - 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) { - ItemDetailsDialog( - info = - ItemDetailsDialogInfo( - title = name, - overview = person.data.overview, - genres = listOf(), - files = listOf(), - ), - showFilePath = false, - onDismissRequest = { showOverviewDialog = false }, - ) } } } @@ -320,7 +227,6 @@ private const val HEADER_ROW = 0 private const val MOVIE_ROW = 1 private const val SERIES_ROW = 2 private const val EPISODE_ROW = 3 -private const val DISCOVER_ROW = EPISODE_ROW + 1 @Composable fun PersonPageContent( @@ -335,13 +241,9 @@ fun PersonPageContent( movies: RowLoadingState, series: RowLoadingState, episodes: RowLoadingState, - discovered: List<DiscoverItem>, onClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, - onClickDiscover: (Int, DiscoverItem) -> Unit, - onClickViewMoreDiscover: () -> Unit, - enableViewMoreDiscover: Boolean, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -358,7 +260,9 @@ fun PersonPageContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), userScrollEnabled = !focusedOnHeader, - modifier = modifier, + modifier = + modifier + .padding(start = 16.dp), ) { item { PersonHeader( @@ -384,7 +288,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.movies_title), movies), + title = rowTitle(stringResource(R.string.movies), movies), state = movies, rowIndex = MOVIE_ROW, position = position, @@ -397,7 +301,7 @@ fun PersonPageContent( } item { LoadingRow( - title = rowTitle(stringResource(R.string.tv_shows_title), series), + title = rowTitle(stringResource(R.string.tv_shows), series), state = series, rowIndex = SERIES_ROW, position = position, @@ -418,52 +322,10 @@ fun PersonPageContent( onClickItem = onClickItem, onClickPosition = { position = it }, showIfEmpty = false, - horizontalPadding = 24.dp, + horizontalPadding = 32.dp, modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(EPISODE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = item?.aspectRatio ?: AspectRatios.FOUR_THREE, - modifier = - mod - .ifElse( - position.row == EPISODE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), - ) - }, ) } - if (discovered.isNotEmpty()) { - item { - DiscoverRow( - row = - DiscoverRowData( - ResStringProvider(R.string.discover), - DataLoadingState.Success(discovered), - DiscoverRequestType.UNKNOWN, - ), - onClickItem = { index: Int, item: DiscoverItem -> - position = RowColumn(DISCOVER_ROW, index) - onClickDiscover.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - onCardFocus = {}, - focusRequester = focusRequester, - enableViewMore = enableViewMoreDiscover, - onClickViewMore = { - position = RowColumn(DISCOVER_ROW, discovered.size) - onClickViewMoreDiscover.invoke() - }, - ) - } - } } } @@ -530,9 +392,8 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.born) + ": ${formatDate(it)} (${ - pluralStringResource( - R.plurals.years_old, - age, + stringResource( + R.string.years_old, age, ) })" @@ -561,9 +422,8 @@ fun PersonHeader( val text = if (age != null) { stringResource(R.string.died) + ": ${formatDate(it)} (${ - pluralStringResource( - R.plurals.years_old, - age, + stringResource( + R.string.years_old, 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 33960f83..09df1d8c 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 @@ -1,755 +1,455 @@ 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.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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.layout.wrapContentWidth +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.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 -import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.preferences.UserPreferences -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.FontAwesome -import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator -import com.github.damontecres.wholphin.ui.cards.WatchedIcon -import com.github.damontecres.wholphin.ui.collectLatestIn -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.components.CollectionFolderState -import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent -import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel -import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope -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.GridClickActions -import com.github.damontecres.wholphin.ui.components.ListItemWrapper -import com.github.damontecres.wholphin.ui.components.MusicContextActions -import com.github.damontecres.wholphin.ui.components.Optional -import com.github.damontecres.wholphin.ui.components.TextButton -import com.github.damontecres.wholphin.ui.components.ViewOptions -import com.github.damontecres.wholphin.ui.components.ViewOptionsType -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker -import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.cards.ItemCardImage +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.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.enableMarquee -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.formatDuration -import com.github.damontecres.wholphin.ui.formatTime -import com.github.damontecres.wholphin.ui.gt -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.settings.MoveDirection +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.roundSeconds -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.util.LocalClock +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.successValue -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +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.playlistsApi -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.GetPlaylistItemsRequest import org.jellyfin.sdk.model.extensions.ticks -import timber.log.Timber import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration -@HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) +@HiltViewModel class PlaylistViewModel - @AssistedInject + @Inject constructor( - private val savedStateHandle: SavedStateHandle, - @ApplicationContext context: Context, api: ApiClient, - navigationManager: NavigationManager, - musicService: MusicService, - mediaManagementService: MediaManagementService, - private val serverRepository: ServerRepository, - @Assisted itemId: UUID, - @Assisted private val collectionState: StateFlow<CollectionFolderState>, - ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { - @AssistedFactory - interface Factory { - fun create( - itemId: UUID, - collectionState: StateFlow<CollectionFolderState>, - ): PlaylistViewModel - } + val navigationManager: NavigationManager, + ) : ItemViewModel(api) { + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + val items = MutableLiveData<List<BaseItem?>>(listOf()) - private val _state = MutableStateFlow(PlaylistDetailsState()) - val state: StateFlow<PlaylistDetailsState> = _state - - val musicState = musicService.state - - var position: Int - get() = savedStateHandle.get<Int>("position") ?: 0 - set(value) { - savedStateHandle["position"] = value - } - - init { - init() - } - - override fun init() { - viewModelScope.launchDefault { - try { - 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(canEdit = canEdit) } - determineMediaType() - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist info for %s", itemId) -// _state.update { it.copy(item = DataLoadingState.Error(ex)) } - } - } - } - - /** - * This method tries to determine the [MediaType] of a playlist - * - * In theory, the server will set the type, but sometimes it doesn't - */ - private fun determineMediaType() { - collectionState - .map { Pair(it.item, it.items) } - .distinctUntilChanged() - .catch { ex -> Timber.e(ex, "Error in determineMediaType") } - .collectLatestIn(viewModelScope) { (item, items) -> - var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN - mediaType = - if (mediaType == MediaType.UNKNOWN) { - // Otherwise, if a most of the list is one type, we can assume that type - val pager = (items.successValue as? ApiRequestPager<*>) - if (pager != null && pager.size <= 50) { - val types = - (0..<50.coerceAtMost(pager.size)).groupBy { index -> - val pagerItem = pager.getBlocking(index) - when (pagerItem?.type) { - BaseItemKind.AUDIO -> MediaType.AUDIO - - BaseItemKind.VIDEO, - BaseItemKind.EPISODE, - BaseItemKind.MOVIE, - BaseItemKind.BOX_SET, - -> MediaType.VIDEO - - else -> MediaType.UNKNOWN - } - } - if (types.keys.size == 1) { - types.keys.first() - } else { - MediaType.UNKNOWN - } - } else { - MediaType.UNKNOWN - } - } else { - mediaType - } - Timber.d("mediaType=%s", mediaType) - _state.update { - it.copy(mediaType = mediaType) - } - } - } - - fun removeFromPlaylist( - index: Int, - itemId: UUID, - ) { - viewModelScope.launchIO { - try { - api.playlistsApi.removeItemFromPlaylist( - this@PlaylistViewModel.itemId.toServerString(), - listOf(itemId.toServerString()), + fun init(playlistId: UUID) { + loading.value = LoadingState.Loading + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), + ) { + val playlist = fetchItem(playlistId) + val request = + GetPlaylistItemsRequest( + playlistId = playlist.id, + fields = DefaultItemFields, ) - (collectionState.value.items.successValue 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}") + val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success } } } - - 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, - ) - (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter( - index - 1, - ) - } - } } -data class PlaylistDetailsState( - val mediaType: MediaType = MediaType.UNKNOWN, - val canEdit: Boolean = false, -) - @Composable fun PlaylistDetails( - preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - collectionViewModel: CollectionFolderViewModel = - hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( - creationCallback = { - it.create( - itemId = destination.itemId.toServerString(), - initialSortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - recursive = false, - collectionFilter = CollectionFolderFilter(), - useSeriesForPrimary = false, - defaultViewOptions = playlistDefaultViewOptions, - ) - }, - ), - viewModel: PlaylistViewModel = - hiltViewModel<PlaylistViewModel, PlaylistViewModel.Factory>( - creationCallback = { it.create(destination.itemId, collectionViewModel.state) }, - ), - addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModel: PlaylistViewModel = hiltViewModel(), ) { - val state by collectionViewModel.state.collectAsState() - val playlistState by viewModel.state.collectAsState() - val musicState by viewModel.musicState.collectAsState() - val focusManager = LocalFocusManager.current - - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var showConfirmTypeDialog by remember { mutableStateOf<Triple<Int, BaseItem, Boolean>?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState() - - fun play( - index: Int, - item: BaseItem, - shuffle: Boolean, - mediaTypeOverride: MediaType? = null, - ) { - when (mediaTypeOverride ?: playlistState.mediaType) { - MediaType.VIDEO -> { - collectionViewModel.navigateTo( - Destination.PlaybackList( - itemId = destination.itemId, - startIndex = index, - shuffle = shuffle, - filter = state.filter, - sortAndDirection = state.sortAndDirection, - ), - ) - } - - MediaType.AUDIO -> { - viewModel.play(item, index, shuffle) - } - - else -> { - showConfirmTypeDialog = Triple(index, item, shuffle) - } - } + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.init(destination.itemId) } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val playlist by viewModel.item.observeAsState(null) + val items by viewModel.items.observeAsState(listOf()) - fun showContextMenu( - index: Int, - item: BaseItem, - fromLongClick: Boolean, - ) { - showContextMenu = - if (item.type == BaseItemKind.AUDIO) { - val musicContextActions = - MusicContextActions( - navigateTo = { collectionViewModel.navigateTo(it) }, - onClickPlay = { index, item -> - play( - index, - item, - false, - MediaType.AUDIO, + var longClickDialog by remember { mutableStateOf<DialogParams?>(null) } + + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier) + LoadingState.Success -> + playlist?.let { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + PlaylistDetailsContent( + playlist = it, + items = items, + focusRequester = focusRequester, + onClickIndex = { index, _ -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it.id, + startIndex = index, + shuffle = false, + ), + ) + }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it.id, + startIndex = 0, + shuffle = shuffle, + ), + ) + }, + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + context.getString(R.string.go_to), + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo(item.destination()) + }, + DialogItem( + context.getString(R.string.play_from_here), + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it.id, + startIndex = index, + shuffle = false, + ), + ) + }, + ), ) - }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> - viewModel.addToQueue( - item, - Int.MAX_VALUE, - ) - }, - onClickFavorite = { id, favorite -> - collectionViewModel.setFavorite(index, id, favorite) - }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = { collectionViewModel.deleteItem(index, item) }, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - ContextMenu.ForMusic( - fromLongClick = fromLongClick, - item = item, - index = index, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveFromQueue = false, - actions = musicContextActions, - showRemoveFromPlaylist = playlistState.canEdit, - ) - } else { - val contextActions = - ContextMenuActions( - navigateTo = { collectionViewModel.navigateTo(it) }, - onClickWatch = { id, watched -> - collectionViewModel.setWatched( - index, - id, - watched, - ) - }, - onClickFavorite = { id, favorite -> - collectionViewModel.setFavorite(index, id, favorite) - }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = collectionViewModel::sendReportFor, - onDeleteItem = { collectionViewModel.deleteItem(index, item) }, - onClickAddToQueue = { viewModel.addToQueue(it, 0) }, - onShowOverview = {}, - onChooseVersion = { _, _ -> }, - onChooseTracks = {}, - onClearChosenStreams = {}, - onClickRemoveFromNextUp = {}, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - ContextMenu.ForBaseItem( - fromLongClick = fromLongClick, - item = item, - index = index, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - showRemoveFromPlaylist = playlistState.canEdit, + }, + modifier = modifier, ) } } - - val actions = - remember { - GridClickActions( - onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) }, - onLongClickItem = { index, item -> showContextMenu(index, item, true) }, - onClickPlayAll = { shuffle -> - state.item.successValue?.let { playlist -> play(0, playlist, shuffle) } - }, - onClickPlayRemoteButton = { index, item -> play(index, item, false) }, - ) - } - CollectionFolderViewContent( - preferences = preferences, - state = state, - savedPosition = viewModel.position, - itemId = destination.itemId.toServerString(), - initialFilter = CollectionFolderFilter(filter = state.filter), - recursive = false, - actions = actions, - sortOptions = PlaylistSortOptions, - playEnabled = true, - defaultViewOptions = playlistDefaultViewOptions, - viewActions = collectionViewModel, - provider = collectionViewModel, - positionCallback = { _, position -> viewModel.position = position }, - modifier = modifier, - listItemContent = { (item, index, onClick, onLongClick, _, mod) -> - PlaylistItem( - item = item, - index = index, - onClick = onClick, - onLongClick = onLongClick, - canMove = playlistState.canEdit, - moveUpAllowed = index > 0, - moveDownAllowed = - state.items.successValue - ?.lastIndex - ?.gt(index) ?: false, - onClickMove = { direction -> - item?.let { viewModel.onMoveItem(index, item.id, direction) } - when (direction) { - MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) - MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) - } - }, - onClickMore = { item?.let { showContextMenu(index, item, false) } }, - modifier = mod, -// interactionSource = TODO(), - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds }, - ) - }, - ) - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showConfirmTypeDialog?.let { (index, item, shuffle) -> - ConfirmMediaTypeDialog( - onConfirm = { mediaType -> play(index, item, shuffle, mediaType) }, - onCancel = { showConfirmTypeDialog = null }, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = addPlaylistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - addToPlaylistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - addToPlaylistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - onSearch = addToPlaylistViewModel::loadPlaylists, - elevation = 3.dp, + longClickDialog?.let { params -> + DialogPopup( + params = params, + onDismissRequest = { longClickDialog = null }, ) } } @Composable -fun CollectionFolderViewScope.PlaylistItem( +fun PlaylistDetailsContent( + playlist: BaseItem, + items: List<BaseItem?>, + onClickIndex: (Int, BaseItem) -> Unit, + onLongClickIndex: (Int, BaseItem) -> Unit, + onClickPlay: (shuffle: Boolean) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + var savedIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedIndex by remember { mutableIntStateOf(savedIndex) } + val focus = remember { FocusRequester() } + val focusedItem = items.getOrNull(focusedIndex) + + val playButtonFocusRequester = remember { FocusRequester() } + + Box( + modifier = modifier, + ) { + if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = focusedItem.backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxHeight(.85f) + .alpha(.4f) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = 500f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + endX = 400f, + startX = 100f, + ), + ) + }, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .padding(top = 16.dp) + .fillMaxSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) { + PlaylistDetailsHeader( + focusedItem = focusedItem, + onClickPlay = onClickPlay, + playButtonFocusRequester = playButtonFocusRequester, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth(.25f), + ) + 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(), + ) + 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), + ).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) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } + } + } + } +} + +@Composable +fun PlaylistDetailsHeader( + focusedItem: BaseItem?, + onClickPlay: (shuffle: Boolean) -> Unit, + playButtonFocusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.focusRequester(playButtonFocusRequester), + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlay.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlay.invoke(true) }, + ) + } + Text( + text = focusedItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = focusedItem?.subtitle ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + OverviewText( + overview = focusedItem?.data?.overview ?: "", + maxLines = 10, + onClick = {}, + enabled = false, + ) + } +} + +@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, - isQueued: Boolean = false, ) { - val dense = viewOptions.type == ViewOptionsType.DENSE_LIST val focused by interactionSource.collectIsFocusedAsState() - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .fillMaxWidth(), -// .heightIn(min = 40.dp, max = 88.dp), - ) { - ListItemWrapper( - dense = dense, - selected = false, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - headlineContent = { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { Text( - text = item?.title ?: "", - modifier = Modifier.enableMarquee(focused), + text = it.toString(), ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - modifier = Modifier.enableMarquee(focused), - ) - }, - trailingContent = { - val duration = - remember(item?.data?.runTimeTicks) { - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } - - else -> { - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes - } - } - } - 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) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column { - Text( - text = durationText, - ) - if (item?.type != BaseItemKind.AUDIO && !dense) { - val style = - LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) } - Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = style, - ) - } - } - if (item?.played == true) { - WatchedIcon(padding = 0.dp) - } - } - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (sortAndDirection.sort == ItemSortBy.DEFAULT) { -// val indexText = -// remember(index, collectionSize, sortAndDirection) { -// when (sortAndDirection.direction) { -// SortOrder.ASCENDING -> "${index + 1}." -// SortOrder.DESCENDING -> "${collectionSize - index}." -// } -// } - // TODO default descending doesn't work? - val indexText = "${index + 1}." - Text( - text = indexText, - ) - } - if (item?.type == BaseItemKind.AUDIO) { - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, - ) - } - if (item?.favorite == true) FavoriteIndicator() - } - }, - 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, - ) - } - Button( - onClick = { onClickMove.invoke(MoveDirection.DOWN) }, - enabled = moveDownAllowed, - contentHeight = contentHeight, - ) { - Text( - text = stringResource(R.string.fa_caret_down), - fontFamily = FontAwesome, - ) - } } - Button( - onClick = onClickMore, - enabled = true, - contentHeight = contentHeight, + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.fa_ellipsis_vertical), - fontFamily = FontAwesome, + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + ItemCardImage( + imageUrl = item?.imageUrl, + 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, + modifier = Modifier.width(160.dp), + useFallbackText = false, ) } - } - } -} - -@Composable -fun ConfirmMediaTypeDialog( - onConfirm: (MediaType) -> Unit, - onCancel: () -> Unit, -) { - BasicDialog( - onDismissRequest = onCancel, - properties = DialogProperties(), - elevation = 3.dp, - ) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(16.dp), - modifier = Modifier.wrapContentSize(), - ) { - item { - Text( - text = stringResource(R.string.play_as_type), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillParentMaxWidth(), - ) - } - - item { - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton( - stringRes = R.string.audio, - onClick = { onConfirm.invoke(MediaType.AUDIO) }, - ) - TextButton( - stringRes = R.string.video, - onClick = { onConfirm.invoke(MediaType.VIDEO) }, - ) - } - } - } - } -} - -private val playlistDefaultViewOptions = - ViewOptions( - spacing = 0, - showDetails = true, - type = ViewOptionsType.LIST, + }, + modifier = modifier, ) +} 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 e38ff099..12ad6ac3 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,16 +1,11 @@ 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,18 +20,14 @@ import androidx.compose.runtime.LaunchedEffect 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.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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color 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 @@ -47,7 +38,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.Button import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme @@ -56,19 +47,14 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.PlaylistInfo 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, @@ -82,37 +68,33 @@ fun PlaylistList( items(playlists) { playlist -> ListItem( selected = false, - enabled = true, + enabled = playlist != null, headlineContent = { Text( - text = playlist.name, + text = playlist?.name ?: stringResource(R.string.loading), ) }, supportingContent = { - 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), - ) + if (playlist != null) { + Text( + text = playlist.mediaType.serialName, + ) + } }, trailingContent = { - Text( - text = - pluralStringResource( - R.plurals.items, - playlist.count, - playlist.count, - ), - ) + if (playlist != null) { + Text( + text = + pluralStringResource( + R.plurals.items, + playlist.count, + playlist.count, + ), + ) + } }, onClick = { - onClick.invoke(playlist) + if (playlist != null) onClick.invoke(playlist) }, modifier = Modifier, ) @@ -130,6 +112,7 @@ fun PlaylistList( leadingContent = { Icon( imageVector = Icons.Default.Add, + tint = Color.Green.copy(.7f), contentDescription = "Add", ) }, @@ -150,7 +133,7 @@ fun PlaylistList( properties = DialogProperties(usePlatformDefaultWidth = false), elevation = 10.dp, ) { - var playlistName by rememberSaveable { mutableStateOf("") } + var playlistName by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( @@ -166,7 +149,9 @@ fun PlaylistList( ) EditTextBox( value = playlistName, - onValueChange = { playlistName = it }, + onValueChange = { + playlistName = it + }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Words, @@ -181,10 +166,6 @@ fun PlaylistList( onCreatePlaylist.invoke(playlistName) }, ), - // onKeyboardAction = { -// showCreateDialog = false -// onCreatePlaylist.invoke(playlistName.text.toString()) -// }, modifier = Modifier .focusRequester(focusRequester) @@ -211,38 +192,16 @@ 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 = @@ -260,78 +219,10 @@ 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, - -> { + -> CircularProgressIndicator( color = MaterialTheme.colorScheme.border, modifier = @@ -339,27 +230,21 @@ fun PlaylistDialog( .align(Alignment.CenterHorizontally) .size(48.dp), ) - } - is PlaylistLoadingState.Error -> { - ErrorMessage( - message = s.message, - exception = s.exception, - modifier = resultsModifier, - ) - } + is PlaylistLoadingState.Error -> + ErrorMessage(s.message, s.exception) is PlaylistLoadingState.Success -> { - LaunchedEffect(Unit) { - // Once results load, focus unless the user is still focused on the search box - if (!searchHasFocus) focusRequester.tryRequestFocus() - } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } PlaylistList( playlists = s.items, onClick = onClick, createEnabled = createEnabled, onCreatePlaylist = onCreatePlaylist, - modifier = resultsModifier, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), ) } } @@ -373,8 +258,7 @@ sealed interface PlaylistLoadingState { data object Loading : PlaylistLoadingState data class Success( - val items: List<PlaylistInfo>, - val query: String, + val items: List<PlaylistInfo?>, ) : PlaylistLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt deleted file mode 100644 index 759da02e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlayArrow -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.graphics.Color -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.ui.components.DeleteButton -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.SortByButton -import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import kotlin.time.Duration - -@Composable -fun CollectionButtons( - state: CollectionState, - onSortChange: (SortAndDirection) -> Unit, - onClickPlayAll: (Boolean) -> Unit, - onFilterChange: (GetItemsFilter) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, - onClickViewOptions: () -> Unit, - favoriteOnClick: () -> Unit, - onConfirmDelete: () -> Unit, - canDelete: Boolean, - moreOnClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val sortOptions = MovieSortOptions - val filterOptions = DefaultFilterOptions - val firstFocus = remember { FocusRequester() } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = modifier, - ) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .focusGroup() - .focusRestorer(firstFocus), - ) { - item { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlayAll.invoke(false) }, - modifier = Modifier.focusRequester(firstFocus), - ) - } - item { - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlayAll.invoke(true) }, - ) - } - - item("favorite") { - val favorite = remember(state.collection) { state.collection?.favorite == true } - ExpandableFaButton( - title = if (favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - onClick = favoriteOnClick, - iconColor = if (favorite) Color.Red else Color.Unspecified, - modifier = Modifier, - ) - } - if (canDelete) { - item("delete") { - DeleteButton( - title = state.collection?.title ?: "", - onConfirmDelete = onConfirmDelete, - modifier = Modifier, - ) - } - } - - item { - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = onClickViewOptions, - modifier = Modifier, - ) - } - - // More button - item("more") { - ExpandablePlayButton( - title = R.string.more, - resume = Duration.ZERO, - icon = Icons.Default.MoreVert, - onClick = { moreOnClick.invoke() }, - modifier = Modifier, - ) - } - } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .focusGroup(), - ) { - item { - SortByButton( - sortOptions = sortOptions, - current = state.sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - item { - FilterByButton( - filterOptions = filterOptions, - current = state.itemFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier, - ) - } - } - } -} 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 deleted file mode 100644 index 291ef74a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ /dev/null @@ -1,458 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Arrangement -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.relocation.BringIntoViewRequester -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.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalContext -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.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.preferences.UserPreferences -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.HeaderUtils -import com.github.damontecres.wholphin.ui.components.HiddenFocusBox -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.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.main.HomePageHeader -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 timber.log.Timber -import java.util.UUID - -@Composable -fun CollectionDetails( - preferences: UserPreferences, - itemId: UUID, - modifier: Modifier = Modifier, - viewModel: CollectionViewModel = - hiltViewModel<CollectionViewModel, CollectionViewModel.Factory>( - creationCallback = { it.create(itemId) }, - ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val state by viewModel.state.collectAsState() - - // Dialogs - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() - var showViewOptionsDialog by remember { mutableStateOf(false) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - - fun contextActionsFor(position: RowColumn?) = - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched, position) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite, position) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = { viewModel.deleteItem(it, position) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - - // Actions - val onClickItem = - remember { - { _: RowColumn, item: BaseItem -> viewModel.navigateTo(item.destination()) } - } - val onLongClickItem = - remember { - { position: RowColumn, 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, - actions = contextActionsFor(position), - ) - } - } - val onSortChange = - remember { - { sort: SortAndDirection -> viewModel.changeSort(sort) } - } - val onFilterChange = - remember { - { filter: GetItemsFilter -> viewModel.changeFilter(filter) } - } - val onClickPlay = { _: RowColumn, item: BaseItem -> - viewModel.navigateTo(Destination.Playback(item = item)) - } - val onClickPlayAll = - remember { - { shuffle: Boolean -> - val dest = - Destination.PlaybackList( - itemId = itemId, - startIndex = 0, - shuffle = shuffle, - recursive = true, - sortAndDirection = state.sortAndDirection, - filter = state.itemFilter, - ) - viewModel.navigateTo(dest) - } - } - val onClickViewOptions = remember { { showViewOptionsDialog = true } } - - when (val s = state.loadingState) { - is LoadingState.Error -> { - ErrorMessage(s, modifier) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - CollectionDetailsContent( - preferences = preferences, - state = state, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onSortChange = onSortChange, - onClickPlay = onClickPlay, - onClickPlayAll = onClickPlayAll, - onChangeBackdrop = viewModel::updateBackdrop, - onFilterChange = onFilterChange, - getPossibleFilterValues = viewModel::getPossibleFilterValues, - letterPosition = viewModel::letterPosition, - onClickViewOptions = onClickViewOptions, - modifier = modifier, - overviewOnClick = { - val collection = state.collection!! - overviewDialog = ItemDetailsDialogInfo(collection) - }, - favoriteOnClick = - remember { - { - state.collection?.let { - viewModel.setFavorite(it.id, !it.favorite, null) - } - } - }, - onConfirmDelete = - remember { - { - state.collection?.let { - viewModel.deleteItem(it, null) - } - } - }, - canDelete = - remember( - state.collection, - preferences.appPreferences.interfacePreferences.enableMediaManagement, - ) { - state.collection?.let { - viewModel.canDelete(it, preferences.appPreferences) - } ?: false - }, - moreOnClick = { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = state.collection!!, - chosenStreams = null, - showGoTo = false, - showStreamChoices = false, - canDelete = - viewModel.canDelete( - state.collection!!, - preferences.appPreferences, - ), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActionsFor(null), - ) - }, - ) - } - } - if (showViewOptionsDialog) { - CollectionViewOptionsDialog( - viewOptions = state.viewOptions, - onDismissRequest = { showViewOptionsDialog = false }, - onViewOptionsChange = viewModel::changeViewOptions, - ) - } - 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() - }, - onSearch = playlistViewModel::loadPlaylists, - elevation = 3.dp, - ) - } -} - -@Composable -fun CollectionDetailsContent( - preferences: UserPreferences, - state: CollectionState, - onClickItem: (RowColumn, BaseItem) -> Unit, - onLongClickItem: (RowColumn, BaseItem) -> Unit, - onSortChange: (SortAndDirection) -> Unit, - onClickPlay: (RowColumn, BaseItem) -> Unit, - onClickPlayAll: (Boolean) -> Unit, - onChangeBackdrop: (BaseItem) -> Unit, - onFilterChange: (GetItemsFilter) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, - letterPosition: suspend (Char) -> Int, - onClickViewOptions: () -> Unit, - overviewOnClick: () -> Unit, - favoriteOnClick: () -> Unit, - onConfirmDelete: () -> Unit, - canDelete: Boolean, - moreOnClick: () -> Unit, - modifier: Modifier, -) { - var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) } - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val focusRequester = remember { FocusRequester() } - val contentFocusRequester = remember { FocusRequester() } - - var focusedItem by remember { mutableStateOf<BaseItem?>(state.collection) } - LaunchedEffect(focusedItem) { - focusedItem?.let { onChangeBackdrop.invoke(it) } - } - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - SharedTransitionLayout { - AnimatedContent( - targetState = itemsContentHasFocus, - label = "header_transition", - ) { targetState -> - if (targetState) { - // Show item header - LaunchedEffect(Unit) { - contentFocusRequester.tryRequestFocus() - } - Column( - Modifier.sharedBounds( - rememberSharedContentState(key = "header"), - animatedVisibilityScope = this@AnimatedContent, - enter = slideInVertically { it / 2 } + fadeIn(), - exit = slideOutVertically { it / 2 } + fadeOut(), - ), - ) { - // This box exists so that there is something focusable above the item content - // allowing focus to move up to restore the collection's header - HiddenFocusBox { - itemsContentHasFocus = false - } - if (state.viewOptions.cardViewOptions.showDetails) { - HomePageHeader( - item = focusedItem, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - modifier = - Modifier - .padding( - start = HeaderUtils.startPadding, - top = HeaderUtils.topPadding, - bottom = 8.dp, - ).height(HeaderUtils.height), - ) - } - } - } else { - // Show collection header - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - focusedItem = state.collection - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .sharedBounds( - rememberSharedContentState(key = "header"), - animatedVisibilityScope = this@AnimatedContent, - enter = slideInVertically { -it / 2 } + fadeIn(), - exit = slideOutVertically { -it / 2 } + fadeOut(), - ).padding(bottom = 16.dp) - .fillMaxWidth() - .onFocusChanged { - if (it.hasFocus) { - onChangeBackdrop.invoke(state.collection!!) - } - }, - ) { - CollectionDetailsHeader( - collection = state.collection!!, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - logoImageUrl = state.logoImageUrl, - overviewOnClick = overviewOnClick, - bringIntoViewRequester = bringIntoViewRequester, - modifier = - Modifier - .padding( - top = HeaderUtils.topPadding, - bottom = HeaderUtils.bottomPadding, - ).height(HeaderUtils.height), - ) - CollectionButtons( - state = state, - onSortChange = onSortChange, - onClickPlayAll = onClickPlayAll, - onFilterChange = onFilterChange, - getPossibleFilterValues = getPossibleFilterValues, - onClickViewOptions = onClickViewOptions, - favoriteOnClick = favoriteOnClick, - onConfirmDelete = onConfirmDelete, - canDelete = canDelete, - moreOnClick = moreOnClick, - modifier = - Modifier - .focusRequester(focusRequester) - .fillMaxWidth(), - ) - } - } - } - } - Box( - modifier = - Modifier - .fillMaxSize() - .onFocusChanged { - if (it.hasFocus) itemsContentHasFocus = true - }.focusProperties { - up = focusRequester - }.focusRequester(contentFocusRequester), - ) { - if (state.viewOptions.separateTypes) { - CollectionRows( - preferences = preferences, - state = state, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - modifier = Modifier.fillMaxSize(), - onFocusPosition = { position -> -// Timber.v("onFocusPosition=%s", position) - focusedItem = - position.let { - val key = - state.separateItems.keys - .toList() - .getOrNull(it.row) - (state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull( - it.column, - ) - } - }, - ) - } else { - CollectionMixedGrid( - preferences = preferences, - state = state, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - letterPosition = letterPosition, - modifier = Modifier.fillMaxSize(), - onFocusPosition = { - Timber.v("onFocusPosition=%s", it) - focusedItem = state.items.getOrNull(it.column) - }, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt deleted file mode 100644 index cbdf1560..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -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.runtime.Composable -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.text.font.FontStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -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.QuickDetails -import com.github.damontecres.wholphin.ui.components.TitleOrLogo -import com.github.damontecres.wholphin.ui.letNotEmpty -import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.launch - -@Composable -fun CollectionDetailsHeader( - collection: BaseItem, - showLogo: Boolean, - logoImageUrl: String?, - overviewOnClick: () -> Unit, - bringIntoViewRequester: BringIntoViewRequester, - modifier: Modifier = Modifier, -) { - val dto = collection.data - val context = LocalContext.current - val scope = rememberCoroutineScope() - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - TitleOrLogo( - title = collection.name, - showLogo = showLogo, - logoImageUrl = logoImageUrl, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = HeaderUtils.startPadding), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(.60f), - ) { - QuickDetails( - collection.ui.quickDetails, - collection.timeRemainingOrRuntime, - Modifier.padding(start = HeaderUtils.startPadding), - ) - - dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) - } - dto.taglines?.firstOrNull()?.let { tagline -> - Text( - text = tagline, - style = MaterialTheme.typography.bodyLarge, - fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) - } - - // Description - dto.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } - } - } -} 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 deleted file mode 100644 index 057c6db2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.playback.scale -import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec -import org.jellyfin.sdk.model.api.ItemSortBy - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun CollectionMixedGrid( - preferences: UserPreferences, - state: CollectionState, - onClickItem: (RowColumn, BaseItem) -> Unit, - onLongClickItem: (RowColumn, BaseItem) -> Unit, - onClickPlay: (RowColumn, BaseItem) -> Unit, - letterPosition: suspend (Char) -> Int, - modifier: Modifier = Modifier, - onFocusPosition: (RowColumn) -> Unit = {}, -) { - val gridFocusRequester = remember { FocusRequester() } - - Box(modifier = modifier) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.fillMaxSize(), - ) { - val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current - val density = LocalDensity.current - - val cardViewOptions = state.viewOptions.cardViewOptions - CardGrid( - pager = state.items, - onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) }, - onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) }, - onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) }, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = - Modifier - .fillMaxSize(), - initialPosition = 0, - positionCallback = { _, newPosition -> - onFocusPosition.invoke(RowColumn(0, newPosition)) - }, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = cardViewOptions.contentScale.scale, - imageAspectRatio = cardViewOptions.aspectRatio.ratio, - imageType = cardViewOptions.imageType, - showTitle = cardViewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) - }, - columns = cardViewOptions.columns, - spacing = cardViewOptions.spacing.dp, - bringIntoViewSpec = - remember(cardViewOptions, density) { - val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } - if (cardViewOptions.showDetails) { - ScrollToTopBringIntoViewSpec(spacingPx) - } else { - defaultBringIntoViewSpec - } - }, - ) - } - } -} 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 deleted file mode 100644 index 126a5765..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.main.HomePageContent -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import org.jellyfin.sdk.model.api.BaseItemKind - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun CollectionRows( - preferences: UserPreferences, - state: CollectionState, - onClickItem: (RowColumn, BaseItem) -> Unit, - onLongClickItem: (RowColumn, BaseItem) -> Unit, - onClickPlay: (RowColumn, BaseItem) -> Unit, - modifier: Modifier = Modifier, - onFocusPosition: (RowColumn) -> Unit = {}, -) { - var position by rememberPosition(0, 0) - - Box(modifier = modifier) { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .fillMaxSize() - .padding(top = 8.dp), - ) { - val cardViewOptions = state.viewOptions.cardViewOptions - val homeRows = - remember(state.separateItems, cardViewOptions) { - state.separateItems.map { (type, row) -> - if (row is HomeRowLoadingState.Success) { - // TODO not great to do this in the UI - val viewOptions = - 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 - } - } - } - HomePageContent( - homeRows = homeRows, - position = position, - onFocusPosition = { newPosition -> - position = newPosition - onFocusPosition.invoke(newPosition) - }, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - showClock = false, - onUpdateBackdrop = {}, - headerComposable = {}, - takeFocus = false, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - showViewMore = false, - modifier = Modifier, - ) - } - } -} 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 deleted file mode 100644 index bdbb059a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ /dev/null @@ -1,528 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -import android.content.Context -import androidx.compose.runtime.Stable -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.ImageUrlService -import com.github.damontecres.wholphin.services.KeyValueService -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.services.ServerReportService -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.collectLatestIn -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.music.addToQueue -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.nav.Destination -import com.github.damontecres.wholphin.ui.toServerString -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.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -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.ImageType -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import java.util.UUID - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class) -class CollectionViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - val serverRepository: ServerRepository, - private val navigationManager: NavigationManager, - private val preferencesService: UserPreferencesService, - private val themeSongPlayer: ThemeSongPlayer, - private val mediaManagementService: MediaManagementService, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - private val keyValueService: KeyValueService, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val imageUrlService: ImageUrlService, - private val musicService: MusicService, - val serverReportService: ServerReportService, - private val filterOptionCache: FilterOptionCache, - @Assisted private val itemId: UUID, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(itemId: UUID): CollectionViewModel - } - - private val viewOptionsFlow = - serverRepository.currentUserFlow - .filterNotNull() - .flatMapLatest { - keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) - - private val libraryDisplayInfoFlow = - serverRepository.currentUserFlow - .filterNotNull() - .flatMapLatest { - libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) - - private val _state = MutableStateFlow(CollectionState()) - val state: StateFlow<CollectionState> = _state - - init { - addCloseable { release() } - // Get global per-user view options for collections - viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions -> - Timber.v("Updated viewOptions") - _state.update { - it.copy(viewOptions = viewOptions) - } - } - libraryDisplayInfoFlow - .filterNotNull() - .collectLatestIn(viewModelScope) { libraryDisplayInfo -> - Timber.v("Updated libraryDisplayInfo") - _state.update { - it.copy( - itemFilter = libraryDisplayInfo.filter, - sortAndDirection = libraryDisplayInfo.sortAndDirection, - ) - } - } - viewModelScope.launchDefault { - 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, - ) - } - 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)) } - } - } - } - - fun release() { - themeSongPlayer.stop() - } - - /** - * Collects on [state] and fetches data when needed - */ - private fun listenForStateUpdates() = - viewModelScope.launchDefault { - state - .map { - Triple( - it.sortAndDirection, - it.itemFilter, - it.viewOptions.separateTypes, - ) - }.distinctUntilChanged() - .collectLatest { (sort, filter, separateTypes) -> - try { - updateData(sort, filter, separateTypes) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e( - ex, - "Error fetching data for collection %s", - itemId, - ) - _state.update { it.copy(loadingState = LoadingState.Error(ex)) } - } - } - } - - private suspend fun updateData( - sort: SortAndDirection, - filter: GetItemsFilter, - separateTypes: Boolean, - ) { - Timber.d("Begin updateData for %s", itemId) - _state.update { - it.copy( - loadingState = LoadingState.Loading, - items = emptyList(), - separateItems = emptyMap(), - ) - } - if (!separateTypes) { - val result = fetchItems(sort, filter, typesInCollection) - _state.update { it.copy(items = result) } - } else { - supervisorScope { - val jobs = - typesInCollection.map { type -> - async(WholphinDispatchers.IO) { - val title = ResStringProvider(formatTypeName(type)) - val result = - try { - val pager = fetchItems(sort, filter, listOf(type)) - HomeRowLoadingState.Success( - title, - pager, - showViewMore = false, - ) - } catch (ex: Exception) { - Timber.e( - ex, - "Error fetching %s for collection %s", - type, - itemId, - ) - HomeRowLoadingState.Error(title, exception = ex) - } - type to result - } - } - jobs.forEach { job -> - val (type, row) = job.await() - // Only include Success if non-empty - if (row !is HomeRowLoadingState.Success || row.items.isNotEmpty()) { - _state.update { - val separateItems = - it.separateItems.toMutableMap().apply { - put(type, row) - } - it.copy(separateItems = separateItems) - } - } - } - } - } - _state.update { it.copy(loadingState = LoadingState.Success) } - Timber.d("End updateData for %s", itemId) - } - - private suspend fun fetchItems( - sort: SortAndDirection?, - filter: GetItemsFilter?, - types: List<BaseItemKind>, - ): ApiRequestPager<GetItemsRequest> { - val request = createGetItemsRequest(sort, filter, types) - val useSeriesForPrimary = !state.value.viewOptions.separateTypes - return ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ).init() - } - - private fun createGetItemsRequest( - sort: SortAndDirection?, - filter: GetItemsFilter?, - types: List<BaseItemKind>, - ): GetItemsRequest { - val includeItemTypes: List<BaseItemKind>? - val excludeItemTypes: List<BaseItemKind>? - // Workaround for https://github.com/jellyfin/jellyfin/issues/16454 - if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) { - includeItemTypes = null - excludeItemTypes = - BaseItemKind.entries - .toMutableList() - .apply { remove(BaseItemKind.BOX_SET) } - } else { - includeItemTypes = types - excludeItemTypes = null - } - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - parentId = itemId, - includeItemTypes = includeItemTypes, - excludeItemTypes = excludeItemTypes, - recursive = false, - sortBy = sort?.let { listOf(sort.sort) }, - sortOrder = sort?.let { listOf(sort.direction) }, - fields = SlimItemFields, - ).let { - filter?.applyTo(it, false) ?: it - } - return request - } - - fun changeSort(sortAndDirection: SortAndDirection) { - viewModelScope.launchIO { - val user = serverRepository.currentUser - val state = _state.value - if (user != null) { - libraryDisplayInfoDao.saveItem( - LibraryDisplayInfo( - user = user, - itemId = itemId, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = state.itemFilter, - viewOptions = null, - ), - ) - } - } - } - - fun changeFilter(filter: GetItemsFilter) { - viewModelScope.launchIO { - val user = serverRepository.currentUser - val state = _state.value - if (user != null) { - libraryDisplayInfoDao.saveItem( - LibraryDisplayInfo( - user = user, - itemId = itemId, - sort = state.sortAndDirection.sort, - direction = state.sortAndDirection.direction, - filter = filter, - viewOptions = null, - ), - ) - } - } - } - - fun changeViewOptions(viewOptions: CollectionViewOptions) { - viewModelScope.launchIO { - if (!viewOptions.cardViewOptions.showDetails) { - val collection = state.value.collection - if (collection != null) { - backdropService.submit(collection) - } else { - backdropService.clearBackdrop() - } - } - serverRepository.currentUser?.id?.let { userId -> - keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) - } - } - } - - suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> = - filterOptionCache.getFilterOptionValues( - itemId, - filterOption, - ) - - suspend fun letterPosition(letter: Char): Int = - withContext(WholphinDispatchers.IO) { - val sort = state.value.sortAndDirection - val filter = state.value.itemFilter - val request = - createGetItemsRequest( - sort = sort, - filter = filter, - types = typesInCollection, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount - } - - fun navigateTo(destination: Destination) { - release() - navigationManager.navigateTo(destination) - } - - fun setWatched( - itemId: UUID, - played: Boolean, - position: RowColumn?, - ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { - favoriteWatchManager.setWatched(itemId, played) - if (itemId == state.value.collection?.id) { - refreshCollection() - } else if (position != null) { - refreshItem(itemId, position, false) - } - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - position: RowColumn?, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - if (itemId == state.value.collection?.id) { - refreshCollection() - } else if (position != null) { - refreshItem(itemId, position, false) - } - } - - private fun refreshCollection() { - viewModelScope.launchDefault { - val collection = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(collection = collection) } - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun deleteItem( - item: BaseItem, - position: RowColumn?, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchIO { - if (position != null) { - refreshItem(itemId, position, true) - } - } - } - } - - private suspend fun refreshItem( - itemId: UUID, - position: RowColumn, - isDelete: Boolean, - ) { - state.value.let { state -> - val items = - if (state.viewOptions.separateTypes) { - val key = - state.separateItems.keys - .toList() - .getOrNull(position.row) - (state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*> - } else { - state.items as? ApiRequestPager<*> - } - if (isDelete) { - items?.refreshPagesAfter(position.column) - } else { - items?.refreshItem(position.column, itemId) - } - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchDefault { - val collection = state.value.collection - if (item.id == collection?.id) { - // Always show the collection's backdrop if requested - backdropService.submit(collection) - } else if (state.value.viewOptions.cardViewOptions.showDetails) { - backdropService.submit(item) - } else { - backdropService.clearBackdrop() - } - } - } - - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) - - companion object { - val typesInCollection = - listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - BaseItemKind.EPISODE, - BaseItemKind.VIDEO, - BaseItemKind.BOX_SET, - BaseItemKind.MUSIC_ARTIST, - BaseItemKind.MUSIC_ALBUM, - BaseItemKind.MUSIC_VIDEO, - ) - - const val VIEW_OPTIONS_KEY = "CollectionViewOptions" - } - } - -@Stable -data class CollectionState( - val loadingState: LoadingState = LoadingState.Pending, - val collection: BaseItem? = null, - val sortAndDirection: SortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - val itemFilter: GetItemsFilter = GetItemsFilter(), - val viewOptions: CollectionViewOptions = CollectionViewOptions(), - val items: List<BaseItem?> = emptyList(), - val separateItems: Map<BaseItemKind, HomeRowLoadingState> = emptyMap(), - val logoImageUrl: String? = null, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt deleted file mode 100644 index b080e817..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.collection - -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 -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppSwitchPreference -import com.github.damontecres.wholphin.ui.components.ViewOptions -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles -import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.serialization.Serializable - -@Composable -fun CollectionViewOptionsDialog( - viewOptions: CollectionViewOptions, - onDismissRequest: () -> Unit, - onViewOptionsChange: (CollectionViewOptions) -> Unit, - defaultViewOptions: CollectionViewOptions = CollectionViewOptions(), -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val columnState = rememberLazyListState() - val options = - if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.END) - window.setDimAmount(0f) - } - Column( - modifier = - Modifier - .width(256.dp) - .heightIn(max = 380.dp) - .focusRequester(focusRequester) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - shape = RoundedCornerShape(8.dp), - ).padding(16.dp), - ) { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - state = columnState, - contentPadding = PaddingValues(0.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - item { - val pref = CollectionViewOptions.SeparateTypes - val interactionSource = remember { MutableInteractionSource() } - ComposablePreference( - preference = pref, - value = viewOptions.separateTypes, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = {}, - ) - } - items(options, key = { it.title }) { pref -> - pref as AppPreference<ViewOptions, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(viewOptions.cardViewOptions) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - val newCardViewOptions = - pref.setter(viewOptions.cardViewOptions, newValue) - onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions)) - }, - interactionSource = interactionSource, - modifier = Modifier.animateItem(), - onClickPreference = { pref -> - if (pref == ViewOptionsReset) { - onViewOptionsChange.invoke(defaultViewOptions) - } - }, - ) - } - } - } - } -} - -@Serializable -data class CollectionViewOptions( - val separateTypes: Boolean = true, - val cardViewOptions: ViewOptions = - ViewOptions( - showDetails = true, - showTitles = true, - ), -) { - companion object { - val SeparateTypes = - AppSwitchPreference<CollectionViewOptions>( - title = R.string.separate_types, - defaultValue = false, - getter = { it.separateTypes }, - setter = { vo, value -> vo.copy(separateTypes = value) }, - ) - - val MixedOptions = - listOf( - ViewOptionsImageType, - ViewOptionsAspectRatio, - ViewOptionsDetailHeader, - ViewOptionsShowTitles, - ViewOptionsColumns, - ViewOptionsSpacing, - ViewOptionsContentScale, - ViewOptionsReset, - ) - - val SeparateOptions = - listOf( - ViewOptionsDetailHeader, - ViewOptionsShowTitles, - ViewOptionsReset, - ) - } -} 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 deleted file mode 100644 index d0017d84..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ /dev/null @@ -1,419 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.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.rememberCoroutineScope -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.LocalContext -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -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.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.github.damontecres.wholphin.data.model.LocalTrailer -import com.github.damontecres.wholphin.data.model.RemoteTrailer -import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.canRequestMovies -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.ui.Cards -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.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 -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus -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 - -@Composable -fun DiscoverMovieDetails( - preferences: UserPreferences, - destination: Destination.DiscoveredItem, - modifier: Modifier = Modifier, - viewModel: DiscoverMovieViewModel = - hiltViewModel<DiscoverMovieViewModel, DiscoverMovieViewModel.Factory>( - creationCallback = { it.create(destination.item) }, - ), -) { - val resources = LocalResources.current - val context = LocalContext.current - LifecycleResumeEffect(Unit) { - viewModel.init() - onPauseOrDispose { } - } - val state by viewModel.state.collectAsState() - val userConfig by viewModel.userConfig.collectAsState(null) - val requestEnabled by viewModel.requestEnabled.collectAsState(false) - val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) - - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showRequestDialog by remember { mutableStateOf(false) } - - when (val st = state.movie) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<MovieDetails> -> { - val movie = st.data - DiscoverMovieDetailsContent( - preferences = preferences, - movie = movie, - userConfig = userConfig, - rating = state.rating, - canCancel = state.canCancelRequest, - people = state.people, - trailers = state.trailers, - similar = state.similar, - recommended = state.recommended, - requestOnClick = { - viewModel.requestOnClick() - showRequestDialog = true - }, - cancelOnClick = { - movie.id?.let { viewModel.cancelRequest(it) } - }, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.title ?: resources.getString(R.string.unknown), - overview = movie.overview, - genres = movie.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - goToOnClick = { - viewModel.goTo(movie.mediaInfo, BaseItemKind.MOVIE) - }, - moreOnClick = {}, - onLongClickPerson = { index, person -> }, - onLongClickSimilar = { index, similar -> - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - modifier = modifier, - ) - if (showRequestDialog) { - RequestMovieDialog( - loading = state.profileLoading, - data = state.requestData, - requestEnabled = requestEnabled, - request4kEnabled = request4kEnabled, - movie = movie, - onSubmit = { - viewModel.request(it) - showRequestDialog = false - }, - onDismissRequest = { showRequestDialog = false }, - ) - } - } - } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = false, - onDismissRequest = { overviewDialog = null }, - ) - } -} - -private const val HEADER_ROW = 0 -private const val PEOPLE_ROW = HEADER_ROW + 1 -private const val CHAPTER_ROW = PEOPLE_ROW + 1 -private const val EXTRAS_ROW = CHAPTER_ROW + 1 -private const val SIMILAR_ROW = EXTRAS_ROW + 1 -private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 - -@Composable -fun DiscoverMovieDetailsContent( - preferences: UserPreferences, - userConfig: SeerrUserConfig?, - movie: MovieDetails, - rating: SourceRatings?, - canCancel: Boolean, - people: List<DiscoverItem>, - trailers: List<Trailer>, - similar: List<DiscoverItem>, - recommended: List<DiscoverItem>, - requestOnClick: () -> Unit, - cancelOnClick: () -> Unit, - trailerOnClick: (Trailer) -> Unit, - overviewOnClick: () -> Unit, - goToOnClick: () -> Unit, - moreOnClick: () -> Unit, - onClickItem: (Int, DiscoverItem) -> Unit, - onClickPerson: (DiscoverItem) -> Unit, - onLongClickPerson: (Int, DiscoverItem) -> Unit, - onLongClickSimilar: (Int, DiscoverItem) -> Unit, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - var position by rememberInt(0) - val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } - - val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } - Box(modifier = modifier) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), - modifier = Modifier.fillMaxSize(), - ) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester), - ) { - DiscoverMovieDetailsHeader( - preferences = preferences, - movie = movie, - rating = rating, - bringIntoViewRequester = bringIntoViewRequester, - overviewOnClick = overviewOnClick, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - modifier = - Modifier - .fillMaxWidth() - .padding(top = HeaderUtils.topPadding, bottom = 16.dp), - ) - ExpandableDiscoverButtons( - availability = - SeerrAvailability.from(movie.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, - requestOnClick = requestOnClick, - cancelOnClick = cancelOnClick, - moreOnClick = moreOnClick, - goToOnClick = goToOnClick, - buttonOnFocusChanged = { - if (it.isFocused) { - position = HEADER_ROW - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - canRequest = userConfig.canRequestMovies, - canCancel = canCancel, - trailers = trailers, - trailerOnClick = trailerOnClick, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .focusRequester(focusRequesters[HEADER_ROW]), - ) - } - } - if (people.isNotEmpty()) { - item { - DiscoverPersonRow( - people = people, - onClick = { - position = PEOPLE_ROW - onClickPerson.invoke(it) - }, - onLongClick = { index, person -> - position = PEOPLE_ROW - onLongClickPerson.invoke(index, person) - }, - modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), - ) - } - } - - if (similar.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.more_like_this), - items = similar, - onClickItem = { index, item -> - position = SIMILAR_ROW - onClickItem.invoke(index, item) - }, - onLongClickItem = { index, similar -> - position = SIMILAR_ROW - onLongClickSimilar.invoke(index, similar) - }, - cardContent = { index, item, mod, onClick, onLongClick -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[SIMILAR_ROW]), - ) - } - } - if (recommended.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.recommended), - items = recommended, - onClickItem = { index, item -> - position = RECOMMENDED_ROW - onClickItem.invoke(index, item) - }, - onLongClickItem = { index, similar -> - position = RECOMMENDED_ROW - onLongClickSimilar.invoke(index, similar) - }, - cardContent = { index, item, mod, onClick, onLongClick -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[RECOMMENDED_ROW]), - ) - } - } - } - } -} - -@Composable -fun TrailerRow( - trailers: List<Trailer>, - onClickTrailer: (Trailer) -> Unit, - modifier: Modifier = Modifier, -) { - val state = rememberLazyListState() - val firstFocus = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.trailers_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(firstFocus), - ) { - itemsIndexed(trailers) { index, item -> - val cardModifier = - if (index == 0) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - } - when (item) { - is LocalTrailer -> { - SeasonCard( - item = item.baseItem, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - showImageOverlay = false, - modifier = cardModifier, - ) - } - - is RemoteTrailer -> { - val subtitle = - when (item.url.toUri().host) { - "youtube.com", "www.youtube.com" -> "YouTube" - else -> null - } - SeasonCard( - title = item.name, - subtitle = subtitle, - name = item.name, - imageUrl = null, - isFavorite = false, - isPlayed = false, - unplayedItemCount = 0, - playedPercentage = 0.0, - numberOfVersions = -1, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - modifier = cardModifier, - showImageOverlay = false, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - ) - } - } - } - } - } -} 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 deleted file mode 100644 index 34bf4657..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ /dev/null @@ -1,166 +0,0 @@ -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.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.unit.Dp -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.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.data.model.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings -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.QuickDetails -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 - -@Composable -fun DiscoverMovieDetailsHeader( - preferences: UserPreferences, - movie: MovieDetails, - rating: SourceRatings?, - 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(4.dp), - modifier = modifier, - ) { - 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 resources = LocalResources.current - val details = - remember(movie, resources) { - buildList { - movie.releaseDate?.let(::add) - movie.runtime - ?.toDouble() - ?.minutes - ?.roundMinutes - ?.let { add(resources.formatDuration(it)) } - val release = - movie.releases - ?.results - ?.firstOrNull { it.iso31661 == Locale.getDefault().country } - ?: movie.releases - ?.results - ?.firstOrNull { it.iso31661 == Locale.US.country } - ?: movie.releases - ?.results - ?.firstOrNull() - - release - ?.releaseDates - ?.firstOrNull() - ?.certification - ?.takeIf { it.isNotNullOrBlank() } - ?.let(::add) - }.let { - QuickDetailsData(listToDotString(it)) - } - } - - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) - movie.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) - } - - val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } - tagline?.let { - Text( - text = tagline, - style = MaterialTheme.typography.bodyLarge, - fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) - } - - // Description - movie.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } - - val directorName = - remember(movie.credits?.crew) { - movie.credits - ?.crew - ?.filter { it.job == "Director" && it.name.isNotNullOrBlank() } - ?.joinToString(", ") { it.name!! } - ?.takeIf { it.isNotNullOrBlank() } - } - - directorName - ?.let { - Text( - 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 deleted file mode 100644 index 407c6935..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ /dev/null @@ -1,294 +0,0 @@ -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 -import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SourceRatings -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 -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrRatingsService -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.services.TmdbVote -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 -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber -import kotlin.coroutines.cancellation.CancellationException - -@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) -class DiscoverMovieViewModel - @AssistedInject - constructor( - private val api: ApiClient, - @param:ApplicationContext private val context: Context, - private val navigationManager: NavigationManager, - private val backdropService: BackdropService, - val serverRepository: ServerRepository, - val seerrService: SeerrService, - private val ratingsService: SeerrRatingsService, - private val seerrServerRepository: SeerrServerRepository, - @Assisted val item: DiscoverItem, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(item: DiscoverItem): DiscoverMovieViewModel - } - - private val _state = MutableStateFlow(DiscoverMovieState()) - val state: StateFlow<DiscoverMovieState> = _state - - val userConfig = seerrServerRepository.current.map { it?.config } - val requestEnabled = - seerrServerRepository.current.map { it?.requestMovieEnabled ?: false } - val request4kEnabled = - seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } - - init { - init() - } - - private fun fetchAndSetItem(): Deferred<MovieDetails?> = - viewModelScope.async(WholphinDispatchers.IO) { - try { - val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) - _state.update { it.copy(movie = DataLoadingState.Success(movie)) } - movie - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error updating movie details") - null - } - } - - fun init(): Job = - viewModelScope.launchIO { - Timber.v("Init for movie %s", item.id) - try { - val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) - _state.update { it.copy(movie = DataLoadingState.Success(movie)) } - val discoveredItem = seerrService.createDiscoverItem(movie) - backdropService.submit(discoveredItem) - - updateCanCancel() - - viewModelScope.launchIO { - val rating = - ratingsService.get( - SeerrItemType.MOVIE, - item.id, - TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), - ) - _state.update { it.copy(rating = rating) } - } - if (state.value.similar.isEmpty()) { - viewModelScope.launchIO { - val result = - seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(similar = result) } - } - viewModelScope.launchIO { - val result = - seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(recommended = result) } - } - } - val people = - movie.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + - movie.credits - ?.crew - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(people = people) } - val trailers = - movie.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - _state.update { it.copy(trailers = trailers) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error updating movie details") - _state.update { it.copy(movie = DataLoadingState.Error(ex)) } - } - } - - private suspend fun updateCanCancel() { - val user = userConfig.firstOrNull() - val canCancel = - canUserCancelRequest( - user, - state.value.movie.successValue - ?.mediaInfo - ?.requests, - ) - _state.update { it.copy(canCancelRequest = canCancel) } - } - - fun navigateTo(destination: Destination) { - navigationManager.navigateTo(destination) - } - - fun goTo( - mediaInfo: MediaInfo?, - type: BaseItemKind, - ) { - viewModelScope.launchDefault { - goToButtonDiscover(mediaInfo, type, context, navigationManager) - } - } - - fun request(request: MovieRequest) { - viewModelScope.launchIO { - if (request.movieId == null) { - Timber.w("Null movie ID") - return@launchIO - } - try { - val postRequest = - RequestPostRequest( - 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(), - ) - Timber.v("postRequest=%s", postRequest) - seerrService.api.requestApi.requestPost(postRequest) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error requesting %s", request.movieId) - showToast(context, "An error occurred") - } - fetchAndSetItem().await() - updateCanCancel() - } - } - - fun cancelRequest(id: Int) { - viewModelScope.launchIO { - state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { - // TODO handle multiple requests? Or just delete self's request? - 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( - user: SeerrUserConfig?, - requests: List<MediaRequest>?, -) = (user.hasPermission(SeerrPermission.MANAGE_REQUESTS) && requests?.isNotEmpty() == true) || - ( - // User requested this - user.hasPermission(SeerrPermission.REQUEST) && - requests - .orEmpty() - .any { - equalsNotNull(it.requestedBy?.id, user?.id) && - RequestStatus.from(it.status) < RequestStatus.APPROVED - } - ) - -data class DiscoverMovieState( - val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending, - val rating: SourceRatings? = null, - val seasons: 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/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt deleted file mode 100644 index 2ce4a3a9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import androidx.compose.foundation.focusable -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.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -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.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.data.ServerRepository -import com.github.damontecres.wholphin.data.model.DiscoverItem -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.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.detail.CardGrid -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 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.update - -@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class) -class DiscoverPersonViewModel - @AssistedInject - constructor( - val navigationManager: NavigationManager, - val serverRepository: ServerRepository, - val seerrService: SeerrService, - private val backdropService: BackdropService, - @Assisted val item: DiscoverItem, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(item: DiscoverItem): DiscoverPersonViewModel - } - - val credits = MutableStateFlow<DataLoadingState<List<DiscoverItem>>>(DataLoadingState.Pending) - - init { - viewModelScope.launchIO { - backdropService.clearBackdrop() - - val credits = seerrService.personCredits(item.id) - this@DiscoverPersonViewModel.credits.update { - DataLoadingState.Success(credits) - } - } - } - } - -@Composable -fun DiscoverPersonPage( - person: DiscoverItem, - startIndex: Int, - modifier: Modifier = Modifier, - viewModel: DiscoverPersonViewModel = - hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>( - creationCallback = { it.create(person) }, - ), -) { - val credits by viewModel.credits.collectAsState() - - when (val state = credits) { - is DataLoadingState.Error -> { - ErrorMessage(state.message, state.exception, modifier.focusable()) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier.focusable()) - } - - is DataLoadingState.Success<List<DiscoverItem>> -> { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (state.data.isNotEmpty()) { - focusRequester.tryRequestFocus() - } - } - Column(modifier = modifier) { - Text( - text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - if (state.data.isEmpty()) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxSize(), - ) - } else { - CardGrid( - pager = state.data, - onClickItem = { index: Int, item: DiscoverItem -> - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) - }, - onLongClickItem = { index: Int, item: DiscoverItem -> - }, - onClickPlay = { _, item -> - }, - initialPosition = startIndex, - letterPosition = { c: Char -> 0 }, - gridFocusRequester = focusRequester, - showJumpButtons = false, - showLetterButtons = false, - spacing = 16.dp, - cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - columns = 6, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } -} 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 deleted file mode 100644 index 860af84e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ /dev/null @@ -1,538 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import android.content.res.Resources -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow -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.rememberCoroutineScope -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.platform.LocalContext -import androidx.compose.ui.platform.LocalResources -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 com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.api.seerr.model.TvDetails -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.canRequestTv -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 -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.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.QuickDetails -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 -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState -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.api.ImageType -import kotlin.time.Duration.Companion.minutes - -@Composable -fun DiscoverSeriesDetails( - preferences: UserPreferences, - destination: Destination.DiscoveredItem, - modifier: Modifier = Modifier, - viewModel: DiscoverSeriesViewModel = - hiltViewModel<DiscoverSeriesViewModel, DiscoverSeriesViewModel.Factory>( - creationCallback = { it.create(destination.item) }, - ), -) { - val context = LocalContext.current - val resources = LocalResources.current - val state by viewModel.state.collectAsState() - val requestEnabled by viewModel.requestEnabled.collectAsState() - 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) } - - when (val st = state.tvSeries) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<TvDetails> -> { - val item = st.data - val userConfig by viewModel.userConfig.collectAsState(null) - DiscoverSeriesDetailsContent( - preferences = preferences, - series = item, - availability = state.availability, - userConfig = userConfig, - rating = state.rating, - canCancel = state.canCancelRequest, - seasons = state.seasons, - people = state.people, - similar = state.similar, - recommended = state.recommended, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - }, - goToOnClick = { - viewModel.goTo(item.mediaInfo, BaseItemKind.SERIES) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = item.name ?: resources.getString(R.string.unknown), - overview = item.overview, - genres = item.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - trailers = state.trailers, - requestOnClick = { - viewModel.requestOnClick() - showRequestSeasonDialog = true - }, - cancelOnClick = { - item.id?.let { viewModel.cancelRequest(it) } - }, - moreOnClick = { - }, - onLongClickPerson = { _, _ -> }, - onLongClickSimilar = { _, _ -> }, - ) - } - } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = false, - onDismissRequest = { overviewDialog = null }, - ) - } - seasonDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - waitToLoad = params.fromLongClick, - onDismissRequest = { seasonDialog = null }, - ) - } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } - if (showRequestSeasonDialog) { - RequestSeasonsDialog( - id = state.tvSeries.successValue?.id ?: -1, - title = state.tvSeries.successValue?.name ?: "", - seasons = state.seasons, - seasons4k = state.seasons4k, - requestEnabled = requestEnabled, - request4kEnabled = request4kEnabled, - onSubmit = { - showRequestSeasonDialog = false - viewModel.request(it) - }, - loading = state.profileLoading, - data = state.requestData, - onDismissRequest = { showRequestSeasonDialog = false }, - ) - } -} - -private const val HEADER_ROW = 0 -private const val SEASONS_ROW = HEADER_ROW + 1 -private const val PEOPLE_ROW = SEASONS_ROW + 1 -private const val EXTRAS_ROW = PEOPLE_ROW + 1 -private const val SIMILAR_ROW = EXTRAS_ROW + 1 -private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 - -@Composable -fun DiscoverSeriesDetailsContent( - preferences: UserPreferences, - userConfig: SeerrUserConfig?, - series: TvDetails, - availability: SeerrAvailability, - rating: SourceRatings?, - canCancel: Boolean, - seasons: List<RequestSeason>, - similar: List<DiscoverItem>, - recommended: List<DiscoverItem>, - trailers: List<Trailer>, - people: List<DiscoverItem>, - requestOnClick: () -> Unit, - cancelOnClick: () -> Unit, - trailerOnClick: (Trailer) -> Unit, - overviewOnClick: () -> Unit, - goToOnClick: () -> Unit, - moreOnClick: () -> Unit, - onClickItem: (Int, DiscoverItem) -> Unit, - onClickPerson: (DiscoverItem) -> Unit, - onLongClickPerson: (Int, DiscoverItem) -> Unit, - onLongClickSimilar: (Int, DiscoverItem) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val bringIntoViewRequester = remember { BringIntoViewRequester() } - - var position by rememberInt() - val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } - val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } - var moreDialog by remember { mutableStateOf<DialogParams?>(null) } - - Box( - modifier = modifier, - ) { - Column( - modifier = - Modifier - .fillMaxSize(), - ) { - LazyColumn( - contentPadding = PaddingValues(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier, - ) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester), - ) { - DiscoverSeriesDetailsHeader( - series = series, - rating = rating, - overviewOnClick = overviewOnClick, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - modifier = - Modifier - .fillMaxWidth() - .padding(top = HeaderUtils.topPadding, bottom = 16.dp), - ) - ExpandableDiscoverButtons( - availability = availability, - requestOnClick = requestOnClick, - pendingOnClick = requestOnClick, - cancelOnClick = cancelOnClick, - moreOnClick = moreOnClick, - goToOnClick = goToOnClick, - buttonOnFocusChanged = { - if (it.isFocused) { - position = HEADER_ROW - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - canRequest = userConfig.canRequestTv, - canCancel = canCancel, - trailers = trailers, - trailerOnClick = trailerOnClick, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .focusRequester(focusRequesters[HEADER_ROW]), - ) - } - } -// item { -// ItemRow( -// title = stringResource(R.string.tv_seasons) + " (${seasons.size})", -// items = seasons, -// onClickItem = { index, item -> -// position = SEASONS_ROW -// // onClickItem.invoke(index, item) -// }, -// onLongClickItem = { index, item -> -// position = SEASONS_ROW -// }, -// modifier = -// Modifier -// .fillMaxWidth() -// .focusRequester(focusRequesters[SEASONS_ROW]), -// cardContent = @Composable { index, item, mod, onClick, onLongClick -> -// SeasonCard( -// item = item, -// onClick = onClick, -// onLongClick = onLongClick, -// imageHeight = Cards.height2x3, -// imageWidth = Dp.Unspecified, -// showImageOverlay = true, -// modifier = mod, -// ) -// }, -// ) -// } - if (people.isNotEmpty()) { - item { - DiscoverPersonRow( - people = people, - onClick = { - position = PEOPLE_ROW - onClickPerson.invoke(it) - }, - onLongClick = { index, person -> - position = PEOPLE_ROW - onLongClickPerson.invoke(index, person) - }, - modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), - ) - } - } - if (similar.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.more_like_this), - items = similar, - onClickItem = { index, item -> - position = SIMILAR_ROW - onClickItem.invoke(index, item) - }, - onLongClickItem = { index, similar -> - position = SIMILAR_ROW - onLongClickSimilar.invoke(index, similar) - }, - cardContent = { index, item, mod, onClick, onLongClick -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[SIMILAR_ROW]), - ) - } - } - if (recommended.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.recommended), - items = recommended, - onClickItem = { index, item -> - position = RECOMMENDED_ROW - onClickItem.invoke(index, item) - }, - onLongClickItem = { index, similar -> - position = RECOMMENDED_ROW - onLongClickSimilar.invoke(index, similar) - }, - cardContent = { index, item, mod, onClick, onLongClick -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[RECOMMENDED_ROW]), - ) - } - } - } - } - } - moreDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { moreDialog = null }, - dismissOnClick = true, - waitToLoad = params.fromLongClick, - ) - } -} - -@Composable -fun DiscoverSeriesDetailsHeader( - series: TvDetails, - rating: SourceRatings?, - 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(4.dp), - modifier = modifier, - ) { - 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 resources = LocalResources.current - val details = - remember(series, resources) { - buildList { - series.firstAirDate?.let(::add) - series.episodeRunTime - ?.average() - ?.takeIf { !it.isNaN() && it > 0 } - ?.minutes - ?.roundMinutes - ?.let { add(resources.formatDuration(it)) } - // TODO - }.let { - QuickDetailsData(listToDotString(it)) - } - } - - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) - series.genres?.mapNotNull { it.name }?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) - } - series.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - ) - } - } - } -} - -fun buildDialogForSeason( - resources: Resources, - s: BaseItem, - onClickItem: (BaseItem) -> Unit, - markPlayed: (Boolean) -> Unit, - onClickPlay: (Boolean) -> Unit, -): DialogParams { - val items = - buildList { - add( - DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { - onClickItem.invoke(s) - }, - ) - if (s.data.userData?.played == true) { - add( - DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { - markPlayed.invoke(false) - }, - ) - } else { - add( - DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { - markPlayed.invoke(true) - }, - ) - } - add( - DialogItem( - resources.getString(R.string.play), - Icons.Default.PlayArrow, - ) { - onClickPlay.invoke(false) - }, - ) - add( - DialogItem( - resources.getString(R.string.shuffle), - R.string.fa_shuffle, - ) { - onClickPlay.invoke(true) - }, - ) - } - return DialogParams( - title = s.name ?: resources.getString(R.string.tv_season), - fromLongClick = true, - items = items, - ) -} 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 deleted file mode 100644 index c20ab9c9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ /dev/null @@ -1,468 +0,0 @@ -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 -import com.github.damontecres.wholphin.api.seerr.model.TvDetails -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SourceRatings -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.SeerrRatingsService -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.services.TmdbVote -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 -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.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) -class DiscoverSeriesViewModel - @AssistedInject - constructor( - private val api: ApiClient, - @param:ApplicationContext private val context: Context, - private val navigationManager: NavigationManager, - private val backdropService: BackdropService, - val serverRepository: ServerRepository, - val seerrService: SeerrService, - private val ratingsService: SeerrRatingsService, - private val seerrServerRepository: SeerrServerRepository, - @Assisted val item: DiscoverItem, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(item: DiscoverItem): DiscoverSeriesViewModel - } - - private val _state = MutableStateFlow(DiscoverSeriesState()) - val state: StateFlow<DiscoverSeriesState> = _state - - val userConfig = seerrServerRepository.current.map { it?.config } - val requestEnabled = - seerrServerRepository.current - .map { it?.requestTvEnabled ?: false } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - val request4kEnabled = - seerrServerRepository.current - .map { it?.request4kTvEnabled ?: false } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - - init { - init() - } - - private fun fetchAndSetItem(): Deferred<TvDetails?> = - viewModelScope.async(WholphinDispatchers.IO) { - try { - val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) - _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } - tv - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error updating tv details") - null - } - } - - fun init(): Job = - viewModelScope.launchIO { - Timber.v("Init for tv %s", item.id) - try { - val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) - _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } - val discoveredItem = seerrService.createDiscoverItem(tv) - backdropService.submit(discoveredItem) - - updateSeasonStatus(tv) - updateAvailability() - - viewModelScope.launchIO { - val rating = - ratingsService.get( - SeerrItemType.TV, - item.id, - TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), - ) - _state.update { it.copy(rating = rating) } - } - if (state.value.similar.isEmpty()) { - viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdSimilarGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(similar = result) } - } - viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdRecommendationsGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(recommended = result) } - } - } - val people = - tv.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + - tv.credits - ?.crew - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - _state.update { it.copy(people = people) } - - val trailers = - tv.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - _state.update { it.copy(trailers = trailers) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error getting tv details") - _state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) } - } - } - - fun navigateTo(destination: Destination) { - navigationManager.navigateTo(destination) - } - - 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, - ) { - Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k) - 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 { seasonNumber -> - seasonStatus[seasonNumber] = RequestStatus.UNKNOWN - val status = if (is4k) it.status4k else it.status - val availability = - SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN - seasonAvailability[seasonNumber] = availability - } - } - 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 - ?.filter { it.is4k == is4k } - ?.forEach { req -> - req.seasons?.mapNotNull { season -> - season.seasonNumber?.let { - val current = seasonStatus[season.seasonNumber] - // 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 || - req.status == RequestStatus.DECLINED.status - ) - } - } - } - Timber.v("seasonAvailability=%s", seasonAvailability) - Timber.v("seasonStatus=%s", seasonStatus) - val requestSeasons = - seasonStatus.mapNotNull { (seasonNumber, status) -> - tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { - 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), - ) - } - } - 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 updateAvailability() { - val user = userConfig.firstOrNull() - val canCancel = - canUserCancelRequest( - user, - state.value.tvSeries.successValue - ?.mediaInfo - ?.requests, - ) - val status = - state.value.tvSeries.successValue?.let { tv -> - val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status) - if (overallStatus == SeerrAvailability.UNKNOWN) { - val userRequests = - tv.mediaInfo - ?.requests - ?.filter { it.requestedBy?.id == user?.id } - .orEmpty() - if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) { - SeerrAvailability.PENDING - } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) { - SeerrAvailability.PROCESSING - } else { - SeerrAvailability.UNKNOWN - } - } else { - overallStatus - } - } - _state.update { - it.copy( - canCancelRequest = canCancel, - availability = status ?: SeerrAvailability.UNKNOWN, - ) - } - } - - 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.status == RequestStatus.PENDING.status && - it.requestedBy?.id == currentUserId - } - 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(), - ), - ) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error requesting %s", request.tvId) - showToast(context, "An error occurred") - } - - fetchAndSetItem().await()?.let { - updateSeasonStatus(it) - updateAvailability() - } - } - } - } - - fun cancelRequest(id: Int) { - viewModelScope.launchIO { - state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { - // TODO handle multiple requests? Or just delete self's request? - 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) - updateAvailability() - } - } - } - } - } - -data class DiscoverSeriesState( - val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending, - val rating: SourceRatings? = 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(), - val availability: SeerrAvailability = SeerrAvailability.UNKNOWN, -) 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 deleted file mode 100644 index cc89159f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import android.content.Context -import com.github.damontecres.wholphin.api.seerr.model.MediaInfo -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 org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber - -/** - * 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 deleted file mode 100644 index c5921b3b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.components.TrailerButton -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlin.time.Duration - -@Composable -fun ExpandableDiscoverButtons( - canRequest: Boolean, - canCancel: Boolean, - availability: SeerrAvailability, - trailers: List<Trailer>?, - requestOnClick: () -> Unit, - cancelOnClick: () -> Unit, - goToOnClick: () -> Unit, - moreOnClick: () -> Unit, - trailerOnClick: (Trailer) -> Unit, - buttonOnFocusChanged: (FocusState) -> Unit, - modifier: Modifier = Modifier, - pendingOnClick: () -> Unit = {}, -) { - val firstFocus = remember { FocusRequester() } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .focusGroup() - .focusRestorer(firstFocus), - ) { - val text = - when (availability) { - SeerrAvailability.UNKNOWN -> R.string.request - - SeerrAvailability.PENDING, - SeerrAvailability.PROCESSING, - -> R.string.pending - - SeerrAvailability.PARTIALLY_AVAILABLE, - SeerrAvailability.AVAILABLE, - -> R.string.go_to - - // TODO - SeerrAvailability.DELETED -> R.string.delete - - // TODO Blocklist - SeerrAvailability.BLOCKLISTED -> R.string.unavailable - } - val icon = - when (availability) { - SeerrAvailability.UNKNOWN -> R.string.fa_download - - SeerrAvailability.PENDING, - SeerrAvailability.PROCESSING, - -> R.string.fa_clock - - SeerrAvailability.PARTIALLY_AVAILABLE, - SeerrAvailability.AVAILABLE, - -> R.string.fa_play - - SeerrAvailability.DELETED -> R.string.fa_video - - // TODO - SeerrAvailability.BLOCKLISTED -> R.string.fa_xmark - } - item("first") { - ExpandableFaButton( - title = text, - iconStringRes = icon, - 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 -> { - requestOnClick.invoke() - } - - SeerrAvailability.PENDING, - SeerrAvailability.PROCESSING, - -> { - pendingOnClick.invoke() - } - - SeerrAvailability.PARTIALLY_AVAILABLE, - SeerrAvailability.AVAILABLE, - -> { - goToOnClick.invoke() - } - - SeerrAvailability.DELETED, - SeerrAvailability.BLOCKLISTED, - -> { - // TODO - } - } - }, - modifier = - Modifier - .focusRequester(firstFocus) - .onFocusChanged(buttonOnFocusChanged), - ) - } - if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) { - item("request_partial") { - ExpandableFaButton( - title = R.string.request, - iconStringRes = R.string.fa_download, - onClick = { - requestOnClick.invoke() - }, - enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } - } - - if (canCancel) { - item("cancel") { - ExpandablePlayButton( - title = R.string.cancel, - icon = Icons.Default.Delete, - onClick = { - firstFocus.tryRequestFocus() - cancelOnClick.invoke() - }, - resume = Duration.ZERO, - enabled = canCancel, - modifier = - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } - } - - if (trailers != null) { - item("trailers") { - TrailerButton( - trailers = trailers, - trailerOnClick = trailerOnClick, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - } - - // More button - // No functionality yet -// item("more") { -// ExpandablePlayButton( -// R.string.more, -// Duration.ZERO, -// Icons.Default.MoreVert, -// { moreOnClick.invoke() }, -// Modifier -// .onFocusChanged(buttonOnFocusChanged), -// ) -// } - } -} 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 deleted file mode 100644 index ef6e54b7..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt +++ /dev/null @@ -1,404 +0,0 @@ -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, - requestEnabled: Boolean, - request4kEnabled: Boolean, - movie: MovieDetails, - onSubmit: (MovieRequest) -> Unit, - onDismissRequest: () -> Unit, -) { - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - RequestMovie( - loading = loading, - data = data, - requestEnabled = requestEnabled, - request4kEnabled = request4kEnabled, - movie = movie, - onSubmit = onSubmit, - modifier = Modifier.padding(24.dp), - ) - } -} - -@Composable -fun RequestMovie( - loading: LoadingState, - data: SeerrRequestData, - requestEnabled: Boolean, - 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() - ) { - // Both enabled, show choice - if (requestEnabled && 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, request4kEnabled, null, null)) - } - } - } else { - RequestMovieWithOptions( - data = data, - requestEnabled = requestEnabled, - request4kEnabled = request4kEnabled, - movie = movie, - onSubmit = onSubmit, - modifier = modifier.focusRequester(focusRequester), - ) - } - } - } -} - -@Composable -private fun RequestMovieWithOptions( - data: SeerrRequestData, - requestEnabled: Boolean, - request4kEnabled: Boolean, - movie: MovieDetails, - onSubmit: (MovieRequest) -> Unit, - modifier: Modifier = Modifier, -) { - var is4k by remember { mutableStateOf(!requestEnabled) } - 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 (requestEnabled && 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, "4K", true), - SeerrProfile(2, "Ultra HD", false), - ), - rootFolders4k = - listOf( - SeerrRootFolder(1, "/movies", "400GB", true), - ), - ), - requestEnabled = true, - request4kEnabled = true, - 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 deleted file mode 100644 index 91a10279..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ /dev/null @@ -1,487 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION -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.Row -import androidx.compose.foundation.layout.fillMaxWidth -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.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 -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Button -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ListItem -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Switch -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>, - seasons4k: List<RequestSeason>, - data: SeerrRequestData, - requestEnabled: Boolean, - request4kEnabled: Boolean, - onSubmit: (TvRequest) -> Unit, - modifier: Modifier = Modifier, -) { - var is4k by remember { mutableStateOf(!requestEnabled) } - 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 - .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(), - ) - } - 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(8.dp), - modifier = modifier, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier, - ) - LazyColumn( - modifier = Modifier, - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - if (requestEnabled && request4kEnabled) { - ClickSwitch( - label = stringResource(R.string.request_4k), - checked = is4k, - onClick = { is4k = !is4k }, - ) - } - Button( - onClick = ::submit, - enabled = selectedSeasons.isNotEmpty(), - ) { - 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, - ) - } - } - } - 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 checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons - SeasonListItem( - season = season, - checked = checked, - onClick = { - if (checked) { - selectedSeasons.remove(seasonNumber) - } else { - seasonNumber?.let { selectedSeasons.add(it) } - } - }, - modifier = Modifier, - ) - } - if (seasons.size > 3) { - item { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Button( - onClick = ::submit, - enabled = selectedSeasons.isNotEmpty(), - ) { - Text( - text = stringResource(R.string.submit), - ) - } - } - } - } - } - } -} - -@Composable -fun SeasonListItem( - season: RequestSeason, - checked: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - ListItem( - enabled = season.editable, - selected = false, - headlineContent = { - Text( - text = - season.season.name - ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } - ?: "", - ) - }, - supportingContent = { - season.season.episodeCount?.let { - Text( - // TODO should use plurals string - text = "${season.season.episodeCount} " + stringResource(R.string.episodes), - ) - } - }, - leadingContent = { - when (season.availability) { - SeerrAvailability.UNKNOWN, - SeerrAvailability.DELETED, - -> { - } - - SeerrAvailability.PENDING, - SeerrAvailability.PROCESSING, - -> { - PendingIndicator() - } - - SeerrAvailability.PARTIALLY_AVAILABLE -> { - PartiallyAvailableIndicator() - } - - SeerrAvailability.AVAILABLE -> { - AvailableIndicator() - } - - SeerrAvailability.BLOCKLISTED -> { - // TODO handle block listed - // BlocklistedIndicator() - } - } - }, - trailingContent = { - Row { - Switch( - checked = checked, - onCheckedChange = { - onClick.invoke() - }, - ) - } - }, - onClick = onClick, - modifier = modifier, - ) -} - -@Composable -private fun ClickSurface( - onClick: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, -) { - Surface( - colors = - ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, - focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), - pressedContainerColor = MaterialTheme.colorScheme.inverseSurface, - pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), - ), - onClick = onClick, - content = content, - modifier = modifier, - ) -} - -@Composable -fun ClickSwitch( - label: String, - checked: Boolean, - onClick: () -> Unit, -) { - ClickSurface( - onClick = onClick, - modifier = Modifier, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(horizontal = 8.dp) - .height(54.dp), - ) { - Switch( - checked = checked, - onCheckedChange = {}, - modifier = Modifier.padding(end = 8.dp), - ) - Text( - text = label, - ) - } - } -} - -@Composable -fun RequestSeasonsDialog( - id: Int, - title: String, - loading: LoadingState, - data: SeerrRequestData, - seasons: List<RequestSeason>, - seasons4k: List<RequestSeason>, - requestEnabled: Boolean, - request4kEnabled: Boolean, - onSubmit: (TvRequest) -> Unit, - onDismissRequest: () -> Unit, -) { - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - 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, - requestEnabled = requestEnabled, - request4kEnabled = request4kEnabled, - onSubmit = onSubmit, - modifier = - Modifier - .padding(16.dp) - .focusRequester(focusRequester), - ) - } - } - } -} - -@Preview( - device = "spec:parent=tv_1080p", - backgroundColor = 0xFF383535, - uiMode = UI_MODE_TYPE_TELEVISION, - heightDp = 800, -) -@Composable -fun RequestSeasonsPreview() { - val seasons = - List(10) { - RequestSeason( - season = - Season( - 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), - ), - ), - requestEnabled = true, - request4kEnabled = true, - 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 deleted file mode 100644 index 355049dc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index b9956fb4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ /dev/null @@ -1,309 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.episode - -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.relocation.BringIntoViewRequester -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -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 com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ChosenStreams -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.components.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.ExpandablePlayButtons -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 -import com.github.damontecres.wholphin.ui.nav.Destination -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.MediaSourceInfo -import org.jellyfin.sdk.model.extensions.ticks -import org.jellyfin.sdk.model.serializer.toUUID -import java.util.UUID -import kotlin.time.Duration - -@Composable -fun EpisodeDetails( - preferences: UserPreferences, - destination: Destination.MediaItem, - modifier: Modifier = Modifier, - viewModel: EpisodeViewModel = - hiltViewModel<EpisodeViewModel, EpisodeViewModel.Factory>( - creationCallback = { it.create(destination.itemId) }, - ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), -) { - LifecycleResumeEffect(Unit) { - viewModel.init() - onPauseOrDispose { } - } - val state by viewModel.state.collectAsState() - - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() - val canDelete by viewModel.canDelete.collectAsState() - - val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) - val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = viewModel::setWatched, - onClickFavorite = viewModel::setFavorite, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = viewModel::deleteItem, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { item, source -> - viewModel.savePlayVersion( - item, - source.id!!.toUUID(), - ) - }, - onChooseTracks = { result -> - viewModel.saveTrackSelection( - result.item, - result.itemPlayback, - result.trackIndex, - result.streamType, - ) - }, - onClearChosenStreams = { viewModel.clearChosenStreams(it) }, - ) - } - - when (val st = state.episode) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<BaseItem> -> { - val ep = st.data - LifecycleResumeEffect(ep) { - ep.data.seriesId?.let { seriesId -> - viewModel.maybePlayThemeSong(seriesId) - } - onPauseOrDispose { - viewModel.release() - } - } - EpisodeDetailsContent( - preferences = preferences, - ep = ep, - chosenStreams = state.chosenStreams, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - ep.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo(ep) - }, - moreOnClick = { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = state.chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = canDelete, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - watchOnClick = { - viewModel.setWatched(ep.id, !ep.played) - }, - favoriteOnClick = { - viewModel.setFavorite(ep.id, !ep.favorite) - }, - canDelete = canDelete, - onConfirmDelete = { viewModel.deleteItem(ep) }, - onChooseVersion = { contextActions.onChooseVersion.invoke(ep, it) }, - modifier = modifier, - ) - } - } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - userDto - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = viewModel.streamChoiceService::chooseSource, - contextMenu = contextMenu, - preferredSubtitleLanguage = preferredSubtitleLanguage, - ) - } - 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, - ) - } -} - -private const val HEADER_ROW = 0 - -@Composable -fun EpisodeDetailsContent( - preferences: UserPreferences, - ep: BaseItem, - chosenStreams: ChosenStreams?, - playOnClick: (Duration) -> Unit, - overviewOnClick: () -> Unit, - watchOnClick: () -> Unit, - favoriteOnClick: () -> Unit, - moreOnClick: () -> Unit, - canDelete: Boolean, - onConfirmDelete: () -> Unit, - onChooseVersion: (MediaSourceInfo) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var position by rememberInt(0) - val focusRequesters = remember { List(1) { FocusRequester() } } - val dto = ep.data - val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO - - val bringIntoViewRequester = remember { BringIntoViewRequester() } - RequestOrRestoreFocus(focusRequesters.getOrNull(position)) - Box(modifier = modifier) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 8.dp), - modifier = Modifier.fillMaxSize(), - ) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester), - ) { - EpisodeDetailsHeader( - preferences = preferences, - ep = ep, - chosenStreams = chosenStreams, - bringIntoViewRequester = bringIntoViewRequester, - overviewOnClick = overviewOnClick, - modifier = - Modifier - .fillMaxWidth() - .padding(top = HeaderUtils.topPadding, bottom = 16.dp), - ) - ExpandablePlayButtons( - title = ep.title ?: "", - resumePosition = resumePosition, - watched = dto.userData?.played ?: false, - favorite = dto.userData?.isFavorite ?: false, - playOnClick = { - position = HEADER_ROW - playOnClick.invoke(it) - }, - moreOnClick = moreOnClick, - watchOnClick = watchOnClick, - favoriteOnClick = favoriteOnClick, - buttonOnFocusChanged = { - if (it.isFocused) { - position = HEADER_ROW - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - trailers = null, - trailerOnClick = {}, - canDelete = canDelete, - onConfirmDelete = onConfirmDelete, - chooseVersionParams = - remember(chosenStreams, ep, onChooseVersion) { - ChooseVersionParams( - chosenStreams = chosenStreams, - mediaSources = ep.data.mediaSources.orEmpty(), - onChooseVersion = onChooseVersion, - ) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .focusRequester(focusRequesters[HEADER_ROW]), - ) - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt deleted file mode 100644 index 3c341ff2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.episode - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.unit.Dp -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.data.ChosenStreams -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.components.SeriesName -import com.github.damontecres.wholphin.ui.components.VideoStreamDetails -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import org.jellyfin.sdk.model.api.PersonKind - -@Composable -fun EpisodeDetailsHeader( - preferences: UserPreferences, - ep: BaseItem, - chosenStreams: ChosenStreams?, - bringIntoViewRequester: BringIntoViewRequester, - overviewOnClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val dto = ep.data - val context = LocalContext.current - val scope = rememberCoroutineScope() - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - SeriesName(dto.seriesName, Modifier.fillMaxWidth(.75f)) - EpisodeName(dto, Modifier.fillMaxWidth(.75f)) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(.60f), - ) { - val padding = 8.dp - QuickDetails(ep, ep.timeRemainingOrRuntime) - - VideoStreamDetails( - chosenStreams = chosenStreams, - numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier.padding(bottom = padding), - ) - dto.taglines?.firstOrNull()?.let { tagline -> - Text( - text = tagline, - style = MaterialTheme.typography.bodyLarge, - fontStyle = FontStyle.Italic, - modifier = Modifier, - ) - } - - // Description - dto.overview?.let { overview -> - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) bringIntoViewRequester.bringIntoView() - } - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - interactionSource = interactionSource, - ) - } - ep.data.people - ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } - ?.joinToString(", ") { it.name!! } - ?.let { - Text( - text = stringResource(R.string.directed_by, it), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } -} 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 deleted file mode 100644 index 97aaaf86..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.episode - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ChosenStreams -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.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.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.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.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.Job -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.MediaStreamType -import timber.log.Timber -import java.util.UUID - -@HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class) -class EpisodeViewModel - @AssistedInject - constructor( - private val api: ApiClient, - @param:ApplicationContext private val context: Context, - private val navigationManager: NavigationManager, - val serverRepository: ServerRepository, - val itemPlaybackRepository: ItemPlaybackRepository, - val streamChoiceService: StreamChoiceService, - val serverReportService: ServerReportService, - private val themeSongPlayer: ThemeSongPlayer, - private val favoriteWatchManager: FavoriteWatchManager, - private val userPreferencesService: UserPreferencesService, - private val backdropService: BackdropService, - private val mediaManagementService: MediaManagementService, - @Assisted val itemId: UUID, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(itemId: UUID): EpisodeViewModel - } - - private val _state = MutableStateFlow(EpisodeState()) - val state: StateFlow<EpisodeState> = _state - - val canDelete = MutableStateFlow(false) - - init { - init() - viewModelScope.launchDefault { - mediaManagementService.collectCanDelete( - state.map { (it.episode as? DataLoadingState.Success<BaseItem?>)?.data }, - ) { canDelete -> - this@EpisodeViewModel.canDelete.update { canDelete } - } - } - } - - private fun fetchAndSetItem() { - viewModelScope.launchIO { - try { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from(it, api) - } - _state.update { it.copy(episode = DataLoadingState.Success(item)) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error getting episode %s", itemId) - showToast(context, "Error updating episode") - } - } - } - - fun init(): Job = - viewModelScope.launchIO { - try { - val prefs = userPreferencesService.getCurrent() - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem(it) - } - val chosenStreams = - itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) - _state.update { - it.copy( - episode = DataLoadingState.Success(item), - chosenStreams = chosenStreams, - ) - } - backdropService.submit(item) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error getting episode %s", itemId) - _state.update { it.copy(episode = DataLoadingState.Error(ex)) } - } - } - - fun setWatched( - itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - fetchAndSetItem() - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - fetchAndSetItem() - } - - fun savePlayVersion( - item: BaseItem, - sourceId: UUID, - ) { - viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() - val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) - } - _state.update { it.copy(chosenStreams = chosen) } - } - } - - fun saveTrackSelection( - item: BaseItem, - itemPlayback: ItemPlayback?, - trackIndex: Int, - type: MediaStreamType, - ) { - viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() - val result = - itemPlaybackRepository.saveTrackSelection( - item = item, - itemPlayback = itemPlayback, - trackIndex = trackIndex, - type = type, - ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) - _state.update { it.copy(chosenStreams = chosen) } - } - } - - fun maybePlayThemeSong(seriesId: UUID) { - viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId) - } - } - - fun release() { - themeSongPlayer.stop() - } - - fun navigateTo(destination: Destination) { - release() - navigationManager.navigateTo(destination) - } - - fun clearChosenStreams(chosenStreams: ChosenStreams?) { - viewModelScope.launchIO { - itemPlaybackRepository.deleteChosenStreams(chosenStreams) - state.value.episode.let { item -> - if (item is DataLoadingState.Success<BaseItem>) { - val result = - itemPlaybackRepository.getSelectedTracks( - itemId, - item.data, - userPreferencesService.getCurrent(), - ) - _state.update { it.copy(chosenStreams = result) } - } - } - } - } - - fun deleteItem(item: BaseItem) { - deleteItem(context, mediaManagementService, item) { - navigationManager.goBack() - } - } - } - -data class EpisodeState( - val episode: DataLoadingState<BaseItem> = DataLoadingState.Pending, - val chosenStreams: ChosenStreams? = null, -) 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 e5bf4e8f..7011975f 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 @@ -5,133 +5,75 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor 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 fun Program( - guideStart: LocalDateTime, program: TvProgram, - colorCode: Boolean, - onClick: () -> Unit, - onLongClick: (() -> Unit)?, + focused: Boolean, modifier: Modifier = Modifier, ) { - val startedBeforeGuide = program.start.isBefore(guideStart) - val shape = - remember(startedBeforeGuide) { - val cornerSize = 4.dp - if (startedBeforeGuide) { - RoundedCornerShape( - topEnd = cornerSize, - bottomEnd = cornerSize, - topStart = 0.dp, - bottomStart = 0.dp, - ) - } else { - RoundedCornerShape(cornerSize) - } + val background = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + program.category?.color + ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) } - val title = program.name ?: program.id.toString() - Surface( - onClick = onClick, - onLongClick = onLongClick, - shape = ClickableSurfaceDefaults.shape(shape), - scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f), - colors = - ClickableSurfaceDefaults.colors( - containerColor = - if (colorCode) { - program.category?.color - ?: MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - } else { - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - }, - contentColor = MaterialTheme.colorScheme.onSurface, - focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, - focusedContentColor = MaterialTheme.colorScheme.inverseOnSurface, - ), + val textColor = MaterialTheme.colorScheme.contentColorFor(background) + Box( modifier = modifier .padding(2.dp) - .fillMaxSize(), + .fillMaxSize() + .background( + background, + shape = RoundedCornerShape(4.dp), + ), ) { - Row( - modifier = Modifier.fillMaxSize(), + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxSize() + .padding(4.dp), ) { - if (startedBeforeGuide) { - Text( - text = stringResource(R.string.fa_caret_left), - fontFamily = FontAwesome, - color = LocalContentColor.current, - fontSize = 16.sp, - modifier = - Modifier - .align(Alignment.CenterVertically) - .padding(start = 2.dp), - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .fillMaxSize() - .padding(start = 2.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), - ) { - Text( - text = title, - color = LocalContentColor.current, - fontSize = 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - val seasonEpisodeLabel = - formatSeasonEpisode( - program.seasonEpisode?.season, - program.seasonEpisode?.episode, - ) - val subtitle = - remember(program, seasonEpisodeLabel) { - listOfNotNull( - seasonEpisodeLabel, - program.subtitle, - ).joinToString(" - ").ifBlank { null } - } - subtitle?.let { + Text( + text = program.name ?: program.id.toString(), + color = textColor, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + listOfNotNull( + program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + program.subtitle, + ).joinToString(" - ") + .ifBlank { null } + ?.let { Text( text = it, - color = LocalContentColor.current, + color = textColor, fontSize = 14.sp, overflow = TextOverflow.Ellipsis, modifier = Modifier, ) } - } } RecordingMarker( isRecording = program.isRecording, @@ -145,84 +87,43 @@ fun Program( fun Channel( channel: TvChannel, channelIndex: Int, - onClick: () -> Unit, - onLongClick: () -> Unit, + focused: Boolean, modifier: Modifier = Modifier, ) { - Surface( - onClick = onClick, - onLongClick = onLongClick, - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), - scale = ClickableSurfaceDefaults.scale(1f, 1f, .95f), - colors = - ClickableSurfaceDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, - ), + val background = + if (focused) { + MaterialTheme.colorScheme.inverseSurface + } else { + MaterialTheme.colorScheme.surface + } + val textColor = MaterialTheme.colorScheme.contentColorFor(background) + Box( modifier = modifier - .background(MaterialTheme.colorScheme.background) - .padding(2.dp) - .fillMaxSize(), + .fillMaxSize() + .background( + background, + shape = RoundedCornerShape(4.dp), + ), ) { - Box( + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .padding(4.dp) .fillMaxSize(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - Text( - text = channel.number ?: "", - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier, - ) { - var showImage by remember { mutableStateOf(true) } - if (showImage) { - AsyncImage( - model = channel.imageUrl, - contentDescription = channel.name, - modifier = Modifier.weight(1f), - onError = { - showImage = false - }, - ) - } - channel.name?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - fontSize = if (showImage) 10.sp else 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier, - ) - } - } - } - if (channel.favorite) { - Text( - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 16.sp, - fontFamily = FontAwesome, - modifier = - Modifier - .padding(2.dp) - .align(Alignment.TopEnd), - ) - } + Text( + text = channel.number ?: channel.name ?: channelIndex.toString(), + color = textColor, + modifier = Modifier, + ) + AsyncImage( + model = channel.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxHeight(.66f), + ) } } } 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 5de558c2..0b124977 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,7 +1,6 @@ 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 @@ -11,8 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -25,6 +24,7 @@ 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.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem @@ -42,16 +42,14 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi -import timber.log.Timber import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId @@ -64,51 +62,36 @@ class DvrScheduleViewModel private val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { - private val _state = MutableStateFlow(DvrScheduleState()) - val state: StateFlow<DvrScheduleState> = _state - - init { - init() - } + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + val active = MutableLiveData<List<BaseItem>>() + val scheduled = MutableLiveData<Map<LocalDate, List<BaseItem>>>() fun init() { - viewModelScope.launchIO { - try { - val active = - api.liveTvApi - .getRecordings( - isInProgress = true, - fields = SlimItemFields, - limit = 100, - ).content.items - .map { BaseItem.from(it, api, true) } - val scheduled = - api.liveTvApi - .getTimers( - isActive = false, - isScheduled = true, - ).content.items - .map { - BaseItem.from( - it.programInfo!!, - api, - true, - ) - } // TODO this probably breaks for time based recordings - .groupBy { - it.data.startDate!!.toLocalDate() - } +// loading.value = LoadingState.Loading + viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) { + val active = + api.liveTvApi + .getRecordings( + isInProgress = true, + fields = SlimItemFields, + limit = 100, + ).content.items + .map { BaseItem.from(it, api, true) } + val scheduled = + api.liveTvApi + .getTimers( + isActive = false, + isScheduled = true, + ).content.items + .map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings + .groupBy { + it.data.startDate!!.toLocalDate() + } - _state.update { - it.copy( - loading = LoadingState.Success, - active = active, - scheduled = scheduled, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching DVR schedule") - _state.update { it.copy(loading = LoadingState.Error(ex)) } + withContext(Dispatchers.Main) { + this@DvrScheduleViewModel.active.value = active + this@DvrScheduleViewModel.scheduled.value = scheduled + loading.value = LoadingState.Success } } } @@ -128,49 +111,34 @@ class DvrScheduleViewModel } } -data class DvrScheduleState( - val loading: LoadingState = LoadingState.Loading, - val active: List<BaseItem> = emptyList(), - val scheduled: Map<LocalDate, List<BaseItem>> = emptyMap(), -) - @Composable fun DvrSchedule( requestFocusAfterLoading: Boolean, - focusRequesterOnEmpty: FocusRequester, modifier: Modifier = Modifier, viewModel: DvrScheduleViewModel = hiltViewModel(), ) { LaunchedEffect(Unit) { viewModel.init() } - val state by viewModel.state.collectAsState() - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val active by viewModel.active.observeAsState(listOf()) + val recordings by viewModel.scheduled.observeAsState(mapOf()) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) LoadingState.Loading, LoadingState.Pending, - -> { - LoadingPage(modifier) - } + -> LoadingPage(modifier) LoadingState.Success -> { var showDialog by remember { mutableStateOf<BaseItem?>(null) } val focusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { - LaunchedEffect(Unit) { - if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) { - focusRequester.tryRequestFocus() - } else { - focusRequesterOnEmpty.tryRequestFocus() - } - } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } } DvrScheduleContent( - activeRecordings = state.active, - scheduledRecordings = state.scheduled, + activeRecordings = active, + scheduledRecordings = recordings, onClickItem = { showDialog = it }, @@ -179,35 +147,29 @@ fun DvrSchedule( .focusRequester(focusRequester), ) showDialog?.let { item -> - val context = LocalContext.current ProgramDialog( - state = DataLoadingState.Success(item), + item = item, canRecord = true, + loading = LoadingState.Success, onDismissRequest = { showDialog = null }, - onWatch = { program -> - val channelId = program.data.channelId - if (channelId != null) { + onWatch = { + item.data.channelId?.let { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = channelId, + itemId = it, positionMs = 0L, ), ) - } else { - Toast - .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) - .show() } }, - onRecord = { _, _ -> + onRecord = { // no-op }, - onCancelRecord = { program, series -> + onCancelRecord = { series -> showDialog = null - val timerId = - if (series) program.data.seriesTimerId else program.data.timerId + val timerId = if (series) item.data.seriesTimerId else item.data.timerId if (timerId != null) { viewModel.cancelRecording(timerId, series) } 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 bcba145c..199c6ce4 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 @@ -1,76 +1,55 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.content.Context -import android.text.format.DateUtils -import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.buildAnnotatedString -import androidx.datastore.core.DataStore +import androidx.lifecycle.MutableLiveData 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.ServerRepository 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.showToast -import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +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.imageApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.model.api.BaseItemDto 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.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.time.LocalDateTime -import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import kotlin.time.toKotlinDuration const val MAX_HOURS = 48L -@OptIn(FlowPreview::class) @HiltViewModel class LiveTvViewModel @Inject @@ -78,172 +57,101 @@ class LiveTvViewModel @param:ApplicationContext private val context: Context, val api: ApiClient, val navigationManager: NavigationManager, - val preferences: DataStore<AppPreferences>, private val serverRepository: ServerRepository, - private val imageUrlService: ImageUrlService, - private val favoriteWatchManager: FavoriteWatchManager, - private val liveTvService: LiveTvService, ) : ViewModel() { + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + + lateinit var guideStart: LocalDateTime + private set private lateinit var channelsIdToIndex: Map<UUID, Int> private val mutex = Mutex() - private val _state = MutableStateFlow(LiveTvState()) - val state: StateFlow<LiveTvState> = _state + val channels = MutableLiveData<List<TvChannel>>() + val channelProgramCount = mutableMapOf<UUID, Int>() + val programs = MutableLiveData<FetchedPrograms>() - private val _programDialogState = MutableStateFlow(ProgramDialogState()) - val programDialogState: StateFlow<ProgramDialogState> = _programDialogState + val fetchingItem = MutableLiveData<LoadingState>(LoadingState.Pending) + val fetchedItem = MutableLiveData<BaseItem?>(null) private val range = 100 - init { - viewModelScope.launchDefault { - preferences.data - .map { - it.interfacePreferences.liveTvPreferences.let { - Pair(it.sortByRecentlyWatched, it.favoriteChannelsAtBeginning) + fun init(firstLoad: Boolean) { + guideStart = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS) + if (!firstLoad) { + loading.value = LoadingState.Loading + } + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Could not fetch channels", + ), + ) { + val channelData by api.liveTvApi.getLiveTvChannels( + GetLiveTvChannelsRequest( + startIndex = 0, + userId = serverRepository.currentUser.value?.id, + enableFavoriteSorting = true, + addCurrentProgram = false, + ), + ) + val channels = + channelData.items + .map { + TvChannel( + it.id, + it.channelNumber, + it.channelName, + api.imageApi.getItemImageUrl(it.id, ImageType.PRIMARY), + ) } - }.distinctUntilChanged() - .debounce { 500.milliseconds } - .collectLatest { - Timber.v("Init due to pref change") - _state.update { LiveTvState() } - init(it.first, it.second) - } - } - } + Timber.d("Got ${channels.size} channels") + channelsIdToIndex = + channels.withIndex().associateBy({ it.value.id }, { it.index }) + // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more + // This makes the guide appear faster, and load more usable data in the background + val initial = 10 + fetchPrograms(channels, 0..<initial.coerceAtMost(channels.size)) - private fun init( - sortByRecentlyWatched: Boolean, - favoriteChannelsAtBeginning: Boolean, - ) { - viewModelScope.launchDefault { - try { - val guideTimes = buildGuideTimes() - _state.update { it.copy(guideTimes = guideTimes) } - val guideStart = guideTimes.first() - val channelData by api.liveTvApi.getLiveTvChannels( - GetLiveTvChannelsRequest( - startIndex = 0, - userId = serverRepository.currentUser?.id, - enableFavoriteSorting = favoriteChannelsAtBeginning, - sortBy = - if (sortByRecentlyWatched) { - listOf(ItemSortBy.DATE_PLAYED) - } else { - null - }, - sortOrder = - if (sortByRecentlyWatched) { - SortOrder.DESCENDING - } else { - null - }, - addCurrentProgram = false, - ), - ) - val channels = - channelData.items - .map { - TvChannel( - id = it.id, - number = it.channelNumber, - name = it.channelName ?: it.name, - imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.PRIMARY, - tag = it.imageTags?.get(ImageType.PRIMARY), - ), - favorite = it.userData?.isFavorite == true, - ) - } - Timber.d("Got ${channels.size} channels") - channelsIdToIndex = - channels.withIndex().associateBy({ it.value.id }, { it.index }) - // Initially, quickly load the first 10 channels (only some are visible immediately), then below will load more - // This makes the guide appear faster, and load more usable data in the background - val initial = 10 - fetchPrograms(guideStart, channels, 0..<initial.coerceAtMost(channels.size)) - - _state.update { - it.copy( - channels = channels, - loading = LoadingState.Success, - ) - } - // Now load the full range - if (channels.size > initial) { - fetchPrograms(guideStart, channels, 0..<range.coerceAtMost(channels.size)) - } - } catch (ex: Exception) { - Timber.e(ex, "Erroring during init") - _state.update { it.copy(loading = LoadingState.Error(ex)) } + withContext(Dispatchers.Main) { + this@LiveTvViewModel.channels.value = channels + loading.value = LoadingState.Success + } + // Now load the full range + if (channels.size > initial) { + fetchPrograms(channels, 0..<range.coerceAtMost(channels.size)) } } } - /** - * Creates a list of [LocalDateTime] for each hour from now - */ - private fun buildGuideTimes() = - buildList { - val start = LocalDateTime.now().roundDownToHalfHour() - add(start) - if (start.minute == 30) { - add(start.plusMinutes(30)) - } - repeat(MAX_HOURS.toInt() - 1) { - add(last().plusHours(1)) - } - } - - private suspend fun refreshPrograms( + private suspend fun fetchProgramsWithLoading( channels: List<TvChannel>, range: IntRange, ) { - _state.update { it.copy(refreshing = true) } - val guideStart = _state.value.guideTimes.first() - try { - fetchPrograms(guideStart, channels, range) - _state.update { it.copy(refreshing = false) } - } catch (_: CancellationException) { - // no-op - } catch (ex: Exception) { - Timber.e(ex, "Error fetching programs") - _state.update { it.copy(loading = LoadingState.Error(ex)) } - } + loading.setValueOnMain(LoadingState.Loading) + fetchPrograms(channels, range) + loading.setValueOnMain(LoadingState.Success) } - /** - * Get live TV programs for a subset of channels - * - * @param guideStart The start timestamp to fetch from - * @param channels The full list of channels - * @param channelIndices The indices of which channels to fetch programs for - */ private suspend fun fetchPrograms( - guideStart: LocalDateTime, channels: List<TvChannel>, - channelIndices: IntRange, + range: IntRange, ) = mutex.withLock { val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1) val minEndDate = guideStart.plusMinutes(1L) - val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1) - Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}") + val channelsToFetch = channels.subList(range.first, range.last + 1) + Timber.v("Fetching programs for $range channels ${channelsToFetch.size}") val request = GetProgramsDto( maxStartDate = maxStartDate, minEndDate = minEndDate, channelIds = channelsToFetch.map { it.id }, sortBy = listOf(ItemSortBy.START_DATE), - userId = serverRepository.currentUser?.id, - fields = listOf(ItemFields.OVERVIEW), ) val fetchedPrograms = api.liveTvApi .getPrograms(request) .content.items - .filter { it.endDate?.isAfter(guideStart) == true } val programsByChannel = mutableMapOf<UUID, List<TvProgram>>() val fetchedGroupedBy = fetchedPrograms.groupBy { it.channelId } fetchedGroupedBy.forEach { (channelId, programs) -> @@ -263,12 +171,6 @@ class LiveTvViewModel } else { null } - // Clean up name/subtitles by collapsing whitespace (including newlines) into single spaces - val name = (dto.seriesName ?: dto.name)?.replace(Regex("\\s+"), " ") - val subtitle = - dto.episodeTitle - .takeIf { dto.isSeries ?: false } - ?.replace(Regex("\\s+"), " ") val p = TvProgram( id = dto.id, @@ -282,16 +184,11 @@ class LiveTvViewModel ).coerceAtLeast(0f), endHours = hoursBetween(guideStart, dto.endDate!!), duration = dto.runTimeTicks!!.ticks, - name = name, - subtitle = subtitle, - overview = dto.overview, - officialRating = dto.officialRating, + name = dto.seriesName ?: dto.name, + subtitle = dto.episodeTitle.takeIf { dto.isSeries ?: false }, seasonEpisode = if (dto.indexNumber != null && dto.parentIndexNumber != null) { - SeasonEpisode( - dto.parentIndexNumber!!, - dto.indexNumber!!, - ) + SeasonEpisode(dto.parentIndexNumber!!, dto.indexNumber!!) } else { null }, @@ -299,12 +196,6 @@ class LiveTvViewModel isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank(), isRepeat = dto.isRepeat ?: false, category = category, - imageUrl = - imageUrlService.getItemImageUrl( - dto.id, - ImageType.PRIMARY, - tag = dto.imageTags?.get(ImageType.PRIMARY), - ), ) if (index == 0) { if (p.startHours > 0) { @@ -325,7 +216,7 @@ class LiveTvViewModel } } listing.add(p) - } else if (index > 0 && listing.isNotEmpty()) { + } else if (index > 0) { var previous = listing.last() while (previous.endHours < p.startHours) { // Fill gaps between programs @@ -405,12 +296,13 @@ class LiveTvViewModel category = ProgramCategory.FAKE, ) } - programsByChannel[channel.id] = fakePrograms + programsByChannel.put(channel.id, fakePrograms) fake.addAll(fakePrograms) } - val channelProgramCount = programsByChannel.map { it.key to it.value.size }.toMap() - + programsByChannel.forEach { (channelId, programs) -> + channelProgramCount[channelId] = programs.size + } val finalProgramList = (programsByChannel.values.flatten()) .sortedWith( @@ -420,30 +312,30 @@ class LiveTvViewModel ), ) Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs") - _state.update { - it.copy( - channelProgramCount = channelProgramCount, - programs = - FetchedPrograms( - channelIndices, - finalProgramList, - programsByChannel, - ), - ) + withContext(Dispatchers.Main) { + this@LiveTvViewModel.programs.value = + FetchedPrograms(range, finalProgramList, programsByChannel) } } - 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 getItem(programId: UUID) { + fetchingItem.value = LoadingState.Loading + viewModelScope.launchIO(LoadingExceptionHandler(fetchingItem, "Error")) { + val result = + api.liveTvApi + .getProgram(programId.toServerString()) + .content + .let { BaseItem.from(it, api) } + withContext(Dispatchers.Main) { + fetchedItem.value = result + fetchingItem.value = LoadingState.Success + } + if (result.data.seriesTimerId != null) { + val items = + api.liveTvApi + .getPrograms(GetProgramsDto(seriesTimerId = result.data.seriesTimerId)) + .content.items + Timber.v("items=$items") } } } @@ -452,19 +344,14 @@ class LiveTvViewModel series: Boolean, timerId: String?, ) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - try { - val result = liveTvService.cancelRecording(series, timerId) - if (result) { - state.value.let { - refreshPrograms(it.channels, it.programs.range) - } + if (timerId != null) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) - showToast(context, "Error: ${ex.localizedMessage}") + fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range) } } } @@ -474,82 +361,79 @@ class LiveTvViewModel series: Boolean, ) { viewModelScope.launchIO { - 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}") + 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) } + fetchProgramsWithLoading(channels.value.orEmpty(), programs.value!!.range) } } private var focusLoadingJob: Job? = null - /** - * Callback when focusing on the EPG grid - * - * This determines if more programs/channels should be fetched based on the current position - */ fun onFocusChannel(position: RowColumn) { - val state = state.value - val channels = state.channels - val fetchedRange = state.programs.range - val quarter = range / 4 - var rangeStart = fetchedRange.start + quarter - var rangeEnd = fetchedRange.last - quarter + channels.value?.let { channels -> + val fetchedRange = programs.value!!.range + val quarter = range / 4 + var rangeStart = fetchedRange.start + quarter + var rangeEnd = fetchedRange.last - quarter - if (rangeEnd - rangeStart < range) { - if (position.row < range / 2) { - // Close to beginning - rangeStart = 0 - } else if (position.row > (channels.size - range / 2)) { - // Close to the end - rangeEnd = channels.size - } - } - val testRange = rangeStart..<rangeEnd - - Timber.v( - "onFocusChannel: position=%s, fetchedRange=%s, testRange=%s", - position, - fetchedRange, - testRange, - ) - - val fetchStart = (position.row - range).coerceAtLeast(0) - val fetchEnd = (position.row + range).coerceAtMost(channels.size) - val newFetchRange = fetchStart..<fetchEnd - // If current channel is not within +/- range - // And the potential new fetch range is not wholly within the current (eg not near the top or bottom) - // Fetch new data - if (position.row !in testRange && !newFetchRange.within(fetchedRange)) { - Timber.v("Loading more programs for channels $newFetchRange") - focusLoadingJob?.cancel() - focusLoadingJob = - viewModelScope.launchIO { - refreshPrograms(channels, newFetchRange) + if (rangeEnd - rangeStart < range) { + if (position.row < range / 2) { + // Close to beginning + rangeStart = 0 + } else if (position.row > (channels.size - range / 2)) { + // Close to the end + rangeEnd = channels.size } - } - } + } + val testRange = rangeStart..<rangeEnd - fun toggleFavorite( - index: Int, - channel: TvChannel, - ) { - viewModelScope.launchIO { - favoriteWatchManager.setFavorite(channel.id, !channel.favorite) - _state.update { - it.copy( - channels = - it.channels.toMutableList().apply { - set(index, channel.copy(favorite = !channel.favorite)) - }, - ) + Timber.v( + "onFocusChannel: position=%s, fetchedRange=%s, testRange=%s", + position, + fetchedRange, + testRange, + ) + + val fetchStart = (position.row - range).coerceAtLeast(0) + val fetchEnd = (position.row + range).coerceAtMost(channels.size) + val newFetchRange = fetchStart..<fetchEnd + // If current channel is not within +/- range + // And the potential new fetch range is not wholly within the current (eg not near the top or bottom) + // Fetch new data + if (position.row !in testRange && !newFetchRange.within(fetchedRange)) { + Timber.v("Loading more programs for channels $newFetchRange") + focusLoadingJob?.cancel() + focusLoadingJob = + viewModelScope.launchIO { + fetchProgramsWithLoading(channels, newFetchRange) + } } } } @@ -568,28 +452,13 @@ fun hoursBetween( .between(start, target) .seconds / (60f * 60f) -data class LiveTvState( - val loading: LoadingState = LoadingState.Pending, - val refreshing: Boolean = false, - val guideTimes: List<LocalDateTime> = emptyList(), - val channels: List<TvChannel> = emptyList(), - val channelProgramCount: Map<UUID, Int> = emptyMap(), - val programs: FetchedPrograms = FetchedPrograms(), -) - -data class ProgramDialogState( - val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending, -) - data class TvChannel( val id: UUID, val number: String?, val name: String?, val imageUrl: String?, - val favorite: Boolean, ) -@Stable data class TvProgram( val id: UUID, val channelId: UUID, @@ -600,60 +469,14 @@ data class TvProgram( val duration: Duration, val name: String?, val subtitle: String?, - val overview: String? = null, val seasonEpisode: SeasonEpisode?, val isRecording: Boolean, val isSeriesRecording: Boolean, val isRepeat: Boolean, val category: ProgramCategory?, - val officialRating: String? = null, - val imageUrl: String? = null, ) { val isFake = category == ProgramCategory.FAKE - val quickDetails by lazy { - val now = LocalDateTime.now() - buildAnnotatedString { - val differentDay = start.toLocalDate() != now.toLocalDate() - val time = - DateUtils.formatDateRange( - WholphinApplication.instance, - start - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - end - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, - ) - append(time) - - if (!isFake) { - val resources = WholphinApplication.instance.resources - dot() - append(resources.formatDuration(duration.roundMinutes)) - if (now.isAfter(start) && now.isBefore(end)) { - dot() - java.time.Duration - .between(now, end) - .toKotlinDuration() - .roundMinutes - .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } - } - formatSeasonEpisode(seasonEpisode?.season, seasonEpisode?.episode)?.let { - dot() - append(it) - } - officialRating?.let { - dot() - append(it) - } - } - } - } - companion object { private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data) @@ -672,7 +495,6 @@ data class TvProgram( duration = ((endHours - startHours) * 60).toInt().minutes, name = NO_DATA, subtitle = null, - overview = null, seasonEpisode = null, isRecording = false, isSeriesRecording = false, @@ -693,12 +515,7 @@ enum class ProgramCategory( } data class FetchedPrograms( - val range: IntRange = 0..0, - val programs: List<TvProgram> = emptyList(), - val programsByChannel: Map<UUID, List<TvProgram>> = emptyMap(), + val range: IntRange, + val programs: List<TvProgram>, + val programsByChannel: Map<UUID, List<TvProgram>>, ) - -fun LocalDateTime.roundDownToHalfHour(): LocalDateTime { - val min = minute % 30L - return minusMinutes(min).truncatedTo(ChronoUnit.MINUTES) -} 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 deleted file mode 100644 index 320ef649..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewOptionsDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.livetv - -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 -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.liveTvPreferences -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.tryRequestFocus - -@Composable -fun LiveTvViewOptionsDialog( - preferences: AppPreferences, - onDismissRequest: () -> Unit, - onViewOptionsChange: (AppPreferences) -> Unit, -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val columnState = rememberLazyListState() - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.END) - window.setDimAmount(0f) - } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .width(256.dp) - .heightIn(max = 380.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - shape = RoundedCornerShape(8.dp), - ), - ) { - 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 04daa260..9f1d5215 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -36,23 +37,23 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.LoadingState import java.time.LocalDateTime import java.time.ZoneId @Composable fun ProgramDialog( - state: DataLoadingState<BaseItem>, + item: BaseItem?, canRecord: Boolean, + loading: LoadingState, onDismissRequest: () -> Unit, - onWatch: (BaseItem) -> Unit, - onRecord: (BaseItem, series: Boolean) -> Unit, - onCancelRecord: (BaseItem, series: Boolean) -> Unit, + onWatch: () -> Unit, + onRecord: (series: Boolean) -> Unit, + onCancelRecord: (series: Boolean) -> Unit, ) { val context = LocalContext.current Dialog( @@ -63,7 +64,7 @@ fun ProgramDialog( modifier = Modifier .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp), + MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp), shape = RoundedCornerShape(16.dp), ).focusRequester(focusRequester), ) { @@ -73,198 +74,196 @@ fun ProgramDialog( Modifier .padding(16.dp), ) { - when (val st = state) { - is DataLoadingState.Error -> { - ErrorMessage(st) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st) + LoadingState.Loading, + LoadingState.Pending, + -> CircularProgress( Modifier .padding(8.dp) .size(48.dp), ) - } - is DataLoadingState.Success<BaseItem> -> { - val item = st.data - val now = LocalDateTime.now() - val dto = item.data - val isRecording = dto.timerId.isNotNullOrBlank() - val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Text( - text = item.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, - ) - if (dto.isSeries ?: false) { - listOfNotNull(dto.seasonEpisode, dto.episodeTitle) - .joinToString(" - ") - .ifBlank { null } - ?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - ) - } - } - val time = - DateUtils.formatDateRange( - context, - dto.startDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - dto.endDate!! - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME, - ) - Text( - text = time, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - dto.overview?.let { overview -> + LoadingState.Success -> + item?.let { item -> + val now = LocalDateTime.now() + val dto = item.data + val isRecording = dto.timerId.isNotNullOrBlank() + val isSeriesRecording = dto.seriesTimerId.isNotNullOrBlank() + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( - text = overview, + text = item.name ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 3, + style = MaterialTheme.typography.titleLarge, ) - } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .padding(top = 8.dp) - .fillMaxWidth(), - ) { - if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { - TextButton( - onClick = { onWatch.invoke(item) }, - modifier = Modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.delete), - ) + if (dto.isSeries ?: false) { + listOfNotNull(dto.seasonEpisode, dto.episodeTitle) + .joinToString(" - ") + .ifBlank { null } + ?.let { Text( - text = stringResource(R.string.watch_live), + text = it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, ) } - } } - if (canRecord) { - val recordFocusRequester = remember { FocusRequester() } - LazyRow( - horizontalArrangement = - Arrangement.spacedBy( - 16.dp, - Alignment.CenterHorizontally, - ), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(recordFocusRequester), - ) { - if (dto.isSeries ?: false) { - item { - TextButton( - onClick = { - if (isSeriesRecording) { - onCancelRecord.invoke(item, true) - } else { - onRecord.invoke(item, true) - } - }, - modifier = - Modifier.focusRequester(recordFocusRequester), - ) { - Row( - horizontalArrangement = - Arrangement.spacedBy( - 4.dp, - ), - verticalAlignment = Alignment.CenterVertically, + val time = + DateUtils.formatDateRange( + context, + dto.startDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + dto.endDate!! + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME, + ) + Text( + text = time, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + dto.overview?.let { overview -> + Text( + text = overview, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .padding(top = 8.dp) + .fillMaxWidth(), + ) { + if (now.isAfter(dto.startDate!!) && now.isBefore(dto.endDate!!)) { + Button( + onClick = onWatch, + modifier = Modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.delete), + tint = Color.Green.copy(alpha = .75f), + ) + Text( + text = stringResource(R.string.watch_live), + ) + } + } + } + if (canRecord) { + val recordFocusRequester = remember { FocusRequester() } + LazyRow( + horizontalArrangement = + Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally, + ), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(recordFocusRequester), + ) { + if (dto.isSeries ?: false) { + item { + Button( + onClick = { + if (isSeriesRecording) { + onCancelRecord.invoke(true) + } else { + onRecord.invoke(true) + } + }, + modifier = + Modifier.focusRequester(recordFocusRequester), ) { - if (isSeriesRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSeriesRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = + if (isSeriesRecording) { + stringResource( + R.string.cancel_series_recording, + ) + } else { + stringResource(R.string.record_series) + }, ) } - Text( - text = - if (isSeriesRecording) { - stringResource( - R.string.cancel_series_recording, - ) - } else { - stringResource(R.string.record_series) - }, - ) } } } - } - if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { - // Only show program specific recording button if it hasn't finished yet - item { - TextButton( - onClick = { - if (isRecording) { - onCancelRecord.invoke(item, false) - } else { - onRecord.invoke(item, false) - } - }, - modifier = - Modifier.ifElse( - !(dto.isSeries ?: false), - Modifier.focusRequester( - recordFocusRequester, + if (dto.endDate?.isAfter(LocalDateTime.now()) ?: true) { + // Only show program specific recording button if it hasn't finished yet + item { + Button( + onClick = { + if (isRecording) { + onCancelRecord.invoke(false) + } else { + onRecord.invoke(false) + } + }, + modifier = + Modifier.ifElse( + !(dto.isSeries ?: false), + Modifier.focusRequester( + recordFocusRequester, + ), ), - ), - ) { - Row( - horizontalArrangement = - Arrangement.spacedBy( - 4.dp, - ), - verticalAlignment = Alignment.CenterVertically, ) { - if (isRecording) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - tint = Color.Red, + Row( + horizontalArrangement = + Arrangement.spacedBy( + 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRecording) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color.Red, + ) + } + Text( + text = + if (isRecording) { + stringResource( + R.string.cancel_recording, + ) + } else { + stringResource( + R.string.record_program, + ) + }, ) } - Text( - text = - if (isRecording) { - stringResource( - R.string.cancel_recording, - ) - } else { - stringResource( - R.string.record_program, - ) - }, - ) } } } @@ -272,7 +271,6 @@ fun ProgramDialog( } } } - } } } } 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 aafaac87..2dc72b8b 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 @@ -2,70 +2,57 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection 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.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.res.stringResource +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme 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.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 -import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage 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.ExceptionHandler 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.launch -import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDateTime import java.time.OffsetDateTime @@ -74,101 +61,58 @@ import kotlin.math.abs @Composable fun TvGuideGrid( - preferences: UserPreferences, + requestFocusAfterLoading: Boolean, 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(preferences.appPreferences) - val tvPrefs = preferences.interfacePreferences.liveTvPreferences - var showViewOptions by remember { mutableStateOf(false) } - var showChannelDialog by remember { mutableStateOf<Pair<Int, TvChannel>?>(null) } - - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } - + var firstLoad by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + viewModel.init(firstLoad) + firstLoad = false + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val channels by viewModel.channels.observeAsState(listOf()) + val programs by viewModel.programs.observeAsState(FetchedPrograms(0..0, listOf(), mapOf())) +// val programsByChannel by viewModel.programsByChannel.observeAsState(mapOf()) +// val fetchedRange by viewModel.fetchedRange.observeAsState(0..0) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) LoadingState.Pending, - -> { - LoadingPage(modifier) - } + -> LoadingPage(modifier) LoadingState.Loading, LoadingState.Success, -> { val context = LocalContext.current - val programDialogState = - viewModel.programDialogState - .collectAsState() - .value.loading - + val fetchedItem by viewModel.fetchedItem.observeAsState(null) + val loadingItem by viewModel.fetchingItem.observeAsState(LoadingState.Pending) var showItemDialog by remember { mutableStateOf<Int?>(null) } val focusRequester = remember { FocusRequester() } - val buttonFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - var focusedPosition by rememberPosition(0, 0) - val focusedProgram = - remember(focusedPosition) { - focusedPosition.let { - val channelId = state.channels.getOrNull(it.row)?.id - state.programs.programsByChannel[channelId]?.getOrNull(it.column) - } + if (requestFocusAfterLoading) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - if (state.channels.isEmpty()) { + } + Column(modifier = modifier) { + if (channels.isEmpty()) { ErrorMessage("Live TV is enabled, but no channels were found.", null) } else { - AnimatedVisibility( - visible = tvPrefs.showHeader, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TvGuideHeader( - program = focusedProgram, - modifier = - Modifier - .padding( - top = HeaderUtils.topPadding, - bottom = 0.dp, - start = HeaderUtils.startPadding, - ).fillMaxHeight(.30f), - ) - } - AnimatedVisibility( - focusedPosition.row < 1, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = { showViewOptions = true }, - modifier = - Modifier - .padding(start = tvGuideDimensions.channelWidth) - .focusRequester(buttonFocusRequester), - ) - } TvGuideGridContent( - preferences = tvPrefs, - refreshing = state.refreshing, - channels = state.channels, - programs = state.programs, - channelProgramCount = state.channelProgramCount, - guideTimes = state.guideTimes, + loading = state is LoadingState.Loading, + channels = channels, + programs = programs, + channelProgramCount = viewModel.channelProgramCount, + start = viewModel.guideStart, onClickChannel = { index, channel -> - showChannelDialog = index to channel + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channel.id, + positionMs = 0L, + ), + ) }, onFocus = { - focusedPosition = it onRowPosition.invoke(it.row) viewModel.onFocusChannel(it) }, @@ -191,192 +135,305 @@ fun TvGuideGrid( ).show() } } else { - viewModel.fetchProgramForDialog(program.id) + viewModel.getItem(program.id) showItemDialog = index } }, - focusRequester = focusRequester, modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.surface) + .focusRequester(focusRequester), ) } } if (showItemDialog != null) { val onDismissRequest = { showItemDialog = null } ProgramDialog( - state = programDialogState, + item = fetchedItem, canRecord = true, + loading = loadingItem, onDismissRequest = onDismissRequest, onWatch = { onDismissRequest.invoke() - val channelId = it.data.channelId - if (channelId != null) { + fetchedItem?.data?.channelId?.let { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = channelId, + itemId = it, 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, - ) + onRecord = { series -> + fetchedItem?.let { + viewModel.record( + programId = it.id, + series = series, + ) + } onDismissRequest.invoke() }, - onCancelRecord = { program, series -> - viewModel.cancelRecording( - series = series, - timerId = if (series) program.data.seriesTimerId else program.data.timerId, - ) + onCancelRecord = { series -> + fetchedItem?.data?.let { + viewModel.cancelRecording( + series = series, + timerId = if (series) it.seriesTimerId else it.timerId, + ) + } onDismissRequest.invoke() }, ) } } } - if (showViewOptions) { - LiveTvViewOptionsDialog( - preferences = preferences, - onDismissRequest = { showViewOptions = false }, - onViewOptionsChange = { newPrefs -> - scope.launchIO { - viewModel.preferences.updateData { - newPrefs - } - } - }, - ) - } - showChannelDialog?.let { (index, channel) -> - val watchLiveStr = stringResource(R.string.watch_live) - val dialogItems = - remember { - listOf( - DialogItem( - watchLiveStr, - Icons.Default.PlayArrow, - dismissOnClick = true, - ) { - viewModel.navigationManager.navigateTo( - Destination.Playback( - itemId = channel.id, - positionMs = 0L, - ), - ) - }, - DialogItem( - text = if (channel.favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - iconColor = if (channel.favorite) Color.Red else Color.Unspecified, - dismissOnClick = true, - ) { - viewModel.toggleFavorite(index, channel) - }, - ) - } - DialogPopup( - onDismissRequest = { showChannelDialog = null }, - params = DialogParams(false, channel.name ?: "", dialogItems), - elevation = 3.dp, - ) - } } -val tvGuideDimensions = - ProgramGuideDimensions( - timelineHourWidth = 240.dp, - timelineHeight = 32.dp, - channelWidth = 120.dp, - channelHeight = 64.dp, - currentTimeWidth = 2.dp, - ) - @Composable fun TvGuideGridContent( - preferences: LiveTvPreferences, - refreshing: Boolean, + loading: Boolean, channels: List<TvChannel>, programs: FetchedPrograms, channelProgramCount: Map<UUID, Int>, - guideTimes: List<LocalDateTime>, + start: LocalDateTime, onClickChannel: (Int, TvChannel) -> Unit, onClickProgram: (Int, TvProgram) -> Unit, onFocus: (RowColumn) -> Unit, - focusRequester: FocusRequester, modifier: Modifier = Modifier, ) { val context = LocalContext.current + val focusManager = LocalFocusManager.current val state = rememberSaveableProgramGuideState() val scope = rememberCoroutineScope() - val guideStart = remember(guideTimes) { guideTimes.first() } - var focusedChannelIndex by rememberInt(-1) var focusedItem by rememberPosition(RowColumn(0, 0)) - var focusedProgramIndex by rememberInt(0) - - LaunchedEffect(onFocus, focusedProgramIndex) { - withContext(WholphinDispatchers.Default) { - val program = programs.programs.getOrNull(focusedProgramIndex) - if (program != null) { - val channelIndex = channels.indexOfFirst { it.id == program.channelId } - val programsBefore = - (0..<channelIndex) - .mapNotNull { - val channel = channels[it] - channelProgramCount[channel.id] - }.sum() - val programIndex = focusedProgramIndex - programsBefore - focusedItem = RowColumn(channelIndex, programIndex) - onFocus.invoke(focusedItem) + val focusedChannelIndex = focusedItem.row + val focusedProgramIndex = + remember(programs.range, focusedItem) { + focusedItem.let { focus -> + (programs.range.first..<focus.row).sumOf { + val channelId = channels[it].id + channelProgramCount[channelId] ?: 0 + } + focus.column } } - } - val programFocusRequester = remember { FocusRequester() } - BackHandler(focusedItem.row > 0) { - scope.launch { - focusedProgramIndex = 0 - state.animateToProgram(0, Alignment.Center) - programFocusRequester.tryRequestFocus() + LaunchedEffect(focusedProgramIndex) { +// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex") + scope.launch(ExceptionHandler()) { + state.animateToProgram(focusedProgramIndex, Alignment.Center) } } - - BackHandler(focusedItem.column > 0) { - scope.launch { - val programIndex = - (0..<focusedItem.row) - .mapNotNull { - val channel = channels[it] - channelProgramCount[channel.id] - }.sum() - focusedProgramIndex = programIndex - state.animateToProgram(programIndex, Alignment.Center) - programFocusRequester.tryRequestFocus() - } - } - - Box( - modifier = modifier, - ) { + val dimensions = + ProgramGuideDimensions( + timelineHourWidth = 240.dp, + timelineHeight = 32.dp, + channelWidth = 120.dp, + channelHeight = 64.dp, + currentTimeWidth = 2.dp, + ) + var gridHasFocus by rememberSaveable { mutableStateOf(false) } + var channelColumnFocused by rememberSaveable { mutableStateOf(false) } + Box(modifier = modifier) { ProgramGuide( state = state, - dimensions = tvGuideDimensions, - modifier = Modifier.fillMaxSize(), + dimensions = dimensions, + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + gridHasFocus = it.hasFocus + }.focusable() + .onPreviewKeyEvent { + if (it.type == KeyEventType.KeyUp) { + return@onPreviewKeyEvent false + } + val item = focusedItem + val newFocusedItem = + when (it.key) { + Key.Back -> { + if (item.column > 0) { + // Not at beginning of row, so move to beginning + item.copy(column = 0) + } else if (item.row > 0) { + item.copy(row = 0) + } else { + // At beginning, so allow normal back button behavior + return@onPreviewKeyEvent false + } + } + + Key.DirectionRight -> { + if (channelColumnFocused) { + channelColumnFocused = false + item.copy(column = 0) + } else { + item.copy(column = item.column + 1) + } + } + + Key.DirectionLeft -> { + if (channelColumnFocused) { + focusManager.moveFocus(FocusDirection.Left) + null + } else if (item.column == 0) { + channelColumnFocused = true + item + } else { + item.copy(column = item.column - 1) + } + } + + Key.DirectionUp -> { + if (item.row <= 0) { + focusManager.moveFocus(FocusDirection.Up) + null + } else { + val newChannelIndex = item.row - 1 + if (channelColumnFocused) { + RowColumn(newChannelIndex, 0) + } else { + val currentChannel = channels[item.row].id + val currentProgram = + programs.programsByChannel[currentChannel]?.get(item.column) + val newChannelId = channels[newChannelIndex].id + val newChannelPrograms = + programs.programsByChannel[newChannelId] + if (newChannelPrograms == null) { + return@onPreviewKeyEvent true + } + if (currentProgram == null) { + item + } else { + val start = currentProgram.startHours + val pIndex = + newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) } + if (pIndex >= 0) { + RowColumn(newChannelIndex, pIndex) + } else { + val pIndex = + newChannelPrograms.indexOfFirst { it.startHours >= start } + if (pIndex >= 0) { + RowColumn(newChannelIndex, pIndex) + } else { + RowColumn(newChannelIndex, 0) + } + } + } + } + } + } + + Key.DirectionDown -> { + // Move channel focus down + val newChannelIndex = item.row + 1 + if (newChannelIndex >= channels.size) { + // If trying to move below the final channel, then move focus out of the grid + focusManager.moveFocus(FocusDirection.Down) + null + } else { + // Otherwise, moving to a new row + // Get the new row/channel's programs + val newChannelId = channels[newChannelIndex].id + val newChannelPrograms = + programs.programsByChannel[newChannelId] + if (newChannelPrograms == null) { + // This means there is no data for the new channel and it is loading in the background + return@onPreviewKeyEvent true + } + if (channelColumnFocused) { + // If focused on the channel column, move down a channel + RowColumn(newChannelIndex, 0) + } else { + // Get current program & its start time + val currentChannel = channels[item.row].id + val currentProgram = + programs.programsByChannel[currentChannel]?.get(item.column) + if (currentProgram == null) { + // Data is loading in the background + item + } else { + val start = currentProgram.startHours + // Find the first program where the start time (of the previously focused program) is in the middle of a program + val pIndex = + newChannelPrograms.indexOfFirst { start in (it.startHours..<it.endHours) } + if (pIndex >= 0) { + // Found one, so focus on it + RowColumn(newChannelIndex, pIndex) + } else { + // Didn't find one, probably due to missing data + // So now first the first one that starts after the previously focused program + val pIndex = + newChannelPrograms.indexOfFirst { it.startHours >= start } + if (pIndex >= 0) { + // Found one, so focus on it + RowColumn(newChannelIndex, pIndex) + } else { + // Did not find one, so focus on the final program in the list + RowColumn( + newChannelIndex, + newChannelPrograms.size - 1, + ) + } + } + } + } + } + } + + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + if (channelColumnFocused) { + val channel = channels[focusedChannelIndex] + Timber.v("Clicked on %s", channel) + onClickChannel.invoke(focusedChannelIndex, channel) + } else { + val currentChannel = channels[item.row].id + val currentProgram = + programs.programsByChannel[currentChannel]?.get(item.column) + if (currentProgram == null) { + // Data is loading in the background + return@onPreviewKeyEvent true + } + Timber.v("Clicked on %s", currentProgram) + onClickProgram.invoke( + focusedProgramIndex, + currentProgram, + ) + } + null + } + + else -> { + null + } + } + + if (newFocusedItem != null) { + val channel = channels[newFocusedItem.row] + val programs = programs.programsByChannel[channel.id].orEmpty() + // Ensure it isn't going out of range + val toFocus = + newFocusedItem + .copy( + row = newFocusedItem.row.coerceIn(0, channels.size - 1), + column = + newFocusedItem.column.coerceIn( + 0, + (programs.size - 1).coerceAtLeast(0), + ), + ) + focusedItem = toFocus + onFocus(toFocus) + return@onPreviewKeyEvent true + } + return@onPreviewKeyEvent false + }, ) { guideStartHour = 0f currentTime( layoutInfo = { ProgramGuideItem.CurrentTime( - hoursBetween(guideStart, LocalDateTime.now()), + hoursBetween(start, LocalDateTime.now()), ) }, ) { @@ -393,18 +450,11 @@ fun TvGuideGridContent( } } timeline( - count = guideTimes.size, + count = MAX_HOURS.toInt(), layoutInfo = { index -> - val start = guideTimes[index] - val end = - if (index < guideTimes.lastIndex) { - guideTimes[index + 1] - } else { - start.plusHours(1) - } ProgramGuideItem.Timeline( - startHour = hoursBetween(guideStart, start), - endHour = hoursBetween(guideStart, end), + startHour = index.toFloat(), + endHour = index + 1f, ) }, ) { index -> @@ -422,12 +472,16 @@ fun TvGuideGridContent( shape = RoundedCornerShape(4.dp), ), ) { - val guideTime = guideTimes[index] - val differentDay = guideTime.toLocalDate() != guideStart.toLocalDate() + val differentDay = + start.toLocalDate() != + start + .plusHours(index.toLong()) + .toLocalDate() val time = DateUtils.formatDateTime( context, - guideTime + start + .plusHours(index.toLong()) .toInstant(OffsetDateTime.now().offset) .epochSecond * 1000, DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, @@ -455,39 +509,9 @@ fun TvGuideGridContent( }, ) { programIndex -> val program = programs.programs[programIndex] - Program( - guideStart = guideStart, - program = program, - colorCode = preferences.colorCodePrograms, - onClick = { - onClickProgram.invoke(programIndex, program) - }, - onLongClick = {}, - modifier = - Modifier - .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 { - state.animateToProgram( - programIndex, - Alignment.Center, - ) - } catch (ex: Exception) { - Timber.e(ex, "Couldn't scroll to $programIndex") - } - } - } - }, - ) + val focused = + gridHasFocus && !channelColumnFocused && programIndex == focusedProgramIndex + Program(program, focused, Modifier) } channels( @@ -497,33 +521,13 @@ fun TvGuideGridContent( }, ) { channelIndex -> val channel = channels[channelIndex] + val focused = + gridHasFocus && channelColumnFocused && focusedChannelIndex == channelIndex Channel( channel = channel, channelIndex = channelIndex, - onClick = { onClickChannel.invoke(channelIndex, channel) }, - onLongClick = {}, - modifier = - Modifier - .ifElse( - focusedChannelIndex == channelIndex, - Modifier.focusRequester(focusRequester), - ).onFocusChanged { - if (it.isFocused) { - focusedChannelIndex = channelIndex - scope.launch { - try { - state.animateToChannel( - channelIndex, - Alignment.CenterVertically, - ) - } catch (ex: Exception) { - Timber.e(ex, "Couldn't scroll to $channelIndex") - } - } - focusedItem = RowColumn(channelIndex, 0) - onFocus.invoke(focusedItem) - } - }, + focused = focused, + modifier = Modifier, ) } @@ -535,7 +539,7 @@ fun TvGuideGridContent( ) } } - if (refreshing) { + if (loading) { CircularProgress( Modifier .background( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt deleted file mode 100644 index d0673c28..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.livetv - -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.AsyncImage -import coil3.compose.useExistingImageAsPlaceholder -import coil3.request.ImageRequest -import coil3.request.transitionFactory -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.CrossFadeFactory -import com.github.damontecres.wholphin.ui.components.EpisodeName -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.StreamLabel -import com.github.damontecres.wholphin.ui.components.TitleOrLogo -import kotlin.time.Duration.Companion.milliseconds - -@OptIn(ExperimentalCoilApi::class) -@Composable -fun TvGuideHeader( - program: TvProgram?, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start, - ) { - TitleOrLogo( - title = program?.name ?: program?.id.toString(), - logoImageUrl = null, - showLogo = false, - modifier = - Modifier - .padding(start = HeaderUtils.startPadding) - .fillMaxWidth(.75f), - ) - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(.6f), - ) { - program?.subtitle?.let { - EpisodeName( - episodeName = program.subtitle, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) { - program?.quickDetails?.let { - QuickDetailsText( - it, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - if (program?.isRepeat == true) { - StreamLabel(stringResource(R.string.live_tv_repeat)) - } - } - OverviewText( - overview = program?.overview ?: "", - maxLines = 3, - onClick = {}, - enabled = false, - ) - } - } - - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(program?.imageUrl) - .transitionFactory(CrossFadeFactory(300.milliseconds)) - .useExistingImageAsPlaceholder(true) - .build(), - contentDescription = null, - ) - } -} 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 ef0d186e..aa90c35d 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 @@ -8,66 +8,79 @@ 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.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState 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.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.data.model.chooseSource 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 import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.ContextMenu -import com.github.damontecres.wholphin.ui.components.ContextMenuActions -import com.github.damontecres.wholphin.ui.components.ContextMenuDialog +import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage +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.ExpandablePlayButtons -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.components.PersonContextActions +import com.github.damontecres.wholphin.ui.components.chooseStream +import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.discover.DiscoverRow -import com.github.damontecres.wholphin.ui.discover.DiscoverRowData -import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.util.ResStringProvider -import com.github.damontecres.wholphin.util.DataLoadingState -import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -89,175 +102,220 @@ fun MovieDetails( viewModel.init() onPauseOrDispose { } } - val state by viewModel.state.collectAsState() + val item by viewModel.item.observeAsState() + val people by viewModel.people.observeAsState(listOf()) + val chapters by viewModel.chapters.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) + val extras by viewModel.extras.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val chosenStreams by viewModel.chosenStreams.observeAsState(null) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) - val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = viewModel::setWatched, - onClickFavorite = viewModel::setFavorite, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = viewModel::deleteItem, - onChooseVersion = { item, source -> - viewModel.savePlayVersion( - item, - source.id!!.toUUID(), - ) - }, - onChooseTracks = { result -> - viewModel.saveTrackSelection( - result.item, - result.itemPlayback, - result.trackIndex, - result.streamType, - ) - }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onClearChosenStreams = { viewModel.clearChosenStreams(it) }, - ) - } - - when (val s = state.loading) { - is DataLoadingState.Error -> { - ErrorMessage(s, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success -> { - val unknownStr = stringResource(R.string.unknown) - val movie by rememberUpdatedState(s.data) - val chosenStreams by rememberUpdatedState(state.chosenStreams) - LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong(destination.itemId) - onPauseOrDispose { - viewModel.release() - } - } - MovieDetailsContent( - preferences = preferences, - movie = movie, - state = state, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - movie.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo(movie) - }, - moreOnClick = { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = movie, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = state.canDelete, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - watchOnClick = { - viewModel.setWatched(movie.id, !movie.played) - }, - favoriteOnClick = { - viewModel.setFavorite(movie.id, !movie.favorite) - }, - onLongClickPerson = { index, person -> - showContextMenu = - ContextMenu.ForPerson( - fromLongClick = true, - person = person, - actions = - PersonContextActions( - navigateTo = viewModel::navigateTo, - onClickFavorite = viewModel::setFavorite, - ), - ) - }, - onLongClickSimilar = { _, similar -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = similar, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = false, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { index, extra -> - viewModel.navigateTo(extra.destination) - }, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - canDelete = state.canDelete, - onConfirmDelete = { state.movie?.let { viewModel.deleteItem(it) } }, - onChooseVersion = { contextActions.onChooseVersion.invoke(movie, it) }, - modifier = modifier, - ) - } - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = viewModel.streamChoiceService::chooseSource, - contextMenu = contextMenu, - preferredSubtitleLanguage = preferredSubtitleLanguage, + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, ) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + LoadingState.Success -> { + item?.let { movie -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } + MovieDetailsContent( + preferences = preferences, + movie = movie, + chosenStreams = chosenStreams, + people = people, + chapters = chapters, + extras = extras, + trailers = trailers, + similar = similar, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + movie.id, + it.inWholeMilliseconds, + movie, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.name ?: context.getString(R.string.unknown), + overview = movie.data.overview, + files = movie.data.mediaSources.orEmpty(), + ) + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.name + " (${movie.data.productionYear ?: ""})", + items = + buildMoreDialogItems( + context = context, + item = movie, + watched = movie.data.userData?.played ?: false, + favorite = movie.data.userData?.isFavorite ?: false, + series = null, + sourceId = chosenStreams?.sourceId, + actions = moreActions, + onChooseVersion = { + chooseVersion = + chooseVersionParams( + context, + movie.data.mediaSources!!, + ) { idx -> + val source = movie.data.mediaSources!![idx] + viewModel.savePlayVersion( + movie, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + chooseSource( + movie.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + movie, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } + }, + ), + ) + }, + watchOnClick = { + viewModel.setWatched(movie.id, !movie.played) + }, + favoriteOnClick = { + viewModel.setFavorite(movie.id, !movie.favorite) + }, + onLongClickPerson = { index, person -> + val items = + buildMoreDialogItemsForPerson( + context = context, + person = person, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) + }, + onLongClickSimilar = { index, similar -> + val items = + buildMoreDialogItemsForHome( + context = context, + item = similar, + seriesId = null, + playbackPosition = similar.playbackPosition, + watched = similar.played, + favorite = similar.favorite, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = similar.title ?: "", + items = items, + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { index, extra -> + viewModel.navigateTo(extra.destination) + }, + modifier = modifier, + ) + } + } } overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - userDto + viewModel.serverRepository.currentUserDto.value ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, ) } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -272,7 +330,6 @@ fun MovieDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, - onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -284,13 +341,17 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 -private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun MovieDetailsContent( preferences: UserPreferences, movie: BaseItem, - state: MovieState, + chosenStreams: ChosenStreams?, + people: List<Person>, + chapters: List<Chapter>, + trailers: List<Trailer>, + extras: List<ExtrasItem>, + similar: List<BaseItem>, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -302,27 +363,25 @@ fun MovieDetailsContent( onLongClickPerson: (Int, Person) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, - onClickDiscover: (Int, DiscoverItem) -> Unit, - canDelete: Boolean, - onConfirmDelete: () -> Unit, - onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) - val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val dto = movie.data + val backdropImageUrl = movie.backdropImageUrl val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - - RequestOrRestoreFocus(focusRequesters.getOrNull(position)) - + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } Box(modifier = modifier) { + DetailsBackdropImage(backdropImageUrl) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(bottom = 8.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -336,16 +395,15 @@ fun MovieDetailsContent( MovieDetailsHeader( preferences = preferences, movie = movie, - chosenStreams = state.chosenStreams, + chosenStreams = chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, modifier = Modifier .fillMaxWidth() - .padding(top = HeaderUtils.topPadding, bottom = 16.dp), + .padding(top = 32.dp, bottom = 16.dp), ) ExpandablePlayButtons( - title = movie.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -364,30 +422,14 @@ fun MovieDetailsContent( } } }, - trailers = state.trailers, - trailerOnClick = { - position = TRAILER_ROW - trailerOnClick.invoke(it) - }, - canDelete = canDelete, - onConfirmDelete = onConfirmDelete, - chooseVersionParams = - remember(state.chosenStreams, movie, onChooseVersion) { - ChooseVersionParams( - chosenStreams = state.chosenStreams, - mediaSources = movie.data.mediaSources.orEmpty(), - onChooseVersion = onChooseVersion, - ) - }, modifier = Modifier - .fillMaxWidth() .padding(bottom = 16.dp) .focusRequester(focusRequesters[HEADER_ROW]), ) } } - state.people.letNotEmpty { people -> + if (people.isNotEmpty()) { item { PersonRow( people = people, @@ -406,10 +448,26 @@ fun MovieDetailsContent( ) } } - state.chapters.letNotEmpty { chapters -> + if (trailers.isNotEmpty()) { + item { + TrailerRow( + trailers = trailers, + onClickTrailer = { + position = TRAILER_ROW + trailerOnClick.invoke(it) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[TRAILER_ROW]), + ) + } + } + if (chapters.isNotEmpty()) { item { ChapterRow( chapters = chapters, + aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE, onClick = { position = CHAPTER_ROW playOnClick.invoke(it.position) @@ -422,7 +480,7 @@ fun MovieDetailsContent( ) } } - state.extras.letNotEmpty { extras -> + if (extras.isNotEmpty()) { item { ExtrasRow( extras = extras, @@ -438,16 +496,8 @@ fun MovieDetailsContent( ) } } - state.similar.letNotEmpty { similar -> + if (similar.isNotEmpty()) { item { - val imageHeight = - remember(movie.type) { - if (movie.type == BaseItemKind.MOVIE) { - Cards.height2x3 - } else { - Cards.heightEpisode - } - } ItemRow( title = stringResource(R.string.more_like_this), items = similar, @@ -466,7 +516,7 @@ fun MovieDetailsContent( onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = imageHeight, + imageHeight = Cards.height2x3, imageWidth = Dp.Unspecified, ) }, @@ -477,23 +527,78 @@ fun MovieDetailsContent( ) } } - state.discovered.letNotEmpty { discovered -> - item { - DiscoverRow( - row = - DiscoverRowData( - ResStringProvider(R.string.discover), - DataLoadingState.Success(discovered), - type = DiscoverRequestType.UNKNOWN, - ), - onClickItem = { index: Int, item: DiscoverItem -> - position = DISCOVER_ROW - onClickDiscover.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - onCardFocus = {}, - focusRequester = focusRequesters[DISCOVER_ROW], - ) + } + } +} + +@Composable +fun TrailerRow( + trailers: List<Trailer>, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 6dbbf965..1ec2c81e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -1,18 +1,21 @@ package com.github.damontecres.wholphin.ui.detail.movie +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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext 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 @@ -21,17 +24,14 @@ import com.github.damontecres.wholphin.R 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.GenreText -import com.github.damontecres.wholphin.ui.components.HeaderUtils +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.letNotEmpty -import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.launch +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.timeRemaining import org.jellyfin.sdk.model.api.PersonKind +import org.jellyfin.sdk.model.extensions.ticks @Composable fun MovieDetailsHeader( @@ -46,85 +46,82 @@ fun MovieDetailsHeader( val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { // Title - TitleOrLogo( - item = movie, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = HeaderUtils.startPadding), + Text( + text = movie.name ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails( - movie, - movie.timeRemainingOrRuntime, - Modifier.padding(start = HeaderUtils.startPadding), + val padding = 8.dp + val details = + remember(dto) { + buildList { + dto.productionYear?.let { add(it.toString()) } + val duration = dto.runTimeTicks?.ticks + duration + ?.roundMinutes + ?.toString() + ?.let(::add) + dto.timeRemaining?.roundMinutes?.let { add("$it left") } + dto.officialRating?.let(::add) + } + } + DotSeparatedRow( + texts = details, + rating = dto.communityRating, + textStyle = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = padding), ) - dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) - } - VideoStreamDetails( - chosenStreams = chosenStreams, - numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = - Modifier.padding( - start = HeaderUtils.startPadding, - top = 4.dp, - bottom = 16.dp, - ), + preferences = preferences, + dto = dto, + itemPlayback = chosenStreams?.itemPlayback, + modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier.padding(start = HeaderUtils.startPadding), + modifier = Modifier, ) } // Description dto.overview?.let { overview -> + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) bringIntoViewRequester.bringIntoView() + } OverviewText( overview = overview, maxLines = 3, onClick = overviewOnClick, textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, + interactionSource = interactionSource, ) } - - val directorName = - remember(movie.data.people) { - movie.data.people - ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } - ?.joinToString(", ") { it.name!! } - ?.takeIf { it.isNotNullOrBlank() } - } - - directorName + movie.data.people + ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } + ?.joinToString(", ") { it.name!! } ?.let { Text( 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/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 9a8dd033..17a68afc 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.movie import android.content.Context +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams @@ -9,46 +10,39 @@ 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.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.preferences.ThemeSongVolume 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.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.ItemRowFields -import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.letNotEmpty 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.ui.setValueOnMain +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState 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.Deferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest -import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = MovieViewModel.Factory::class) @@ -56,21 +50,15 @@ class MovieViewModel @AssistedInject constructor( private val api: ApiClient, - private val seerrService: SeerrService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, - val streamChoiceService: StreamChoiceService, - val serverReportService: ServerReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, private val trailerService: TrailerService, private val extrasService: ExtrasService, - private val userPreferencesService: UserPreferencesService, - private val backdropService: BackdropService, - private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -78,142 +66,104 @@ class MovieViewModel fun create(itemId: UUID): MovieViewModel } - private val _state = MutableStateFlow(MovieState()) - val state: StateFlow<MovieState> = _state + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + val item = MutableLiveData<BaseItem?>(null) + val trailers = MutableLiveData<List<Trailer>>(listOf()) + val people = MutableLiveData<List<Person>>(listOf()) + val chapters = MutableLiveData<List<Chapter>>(listOf()) + val extras = MutableLiveData<List<ExtrasItem>>(listOf()) + val similar = MutableLiveData<List<BaseItem>>() + val chosenStreams = MutableLiveData<ChosenStreams?>(null) init { init() - viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(state.map { it.movie }) { canDelete -> - _state.update { - it.copy(canDelete = canDelete) - } - } - } } - private suspend fun getMovie(): BaseItem { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem(it) - } - return item - } + private fun fetchAndSetItem(): Deferred<BaseItem> = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from(it, api) + } + this@MovieViewModel.item.setValueOnMain(item) + item + } fun init(): Job = - viewModelScope.launchDefault { - val movie = - try { - getMovie() - } catch (ex: Exception) { - Timber.e(ex, "Failed to fetch movie %s", itemId) - _state.update { it.copy(loading = DataLoadingState.Error(ex)) } - return@launchDefault - } - val chosenStreams = - itemPlaybackRepository.getSelectedTracks( - itemId, - movie, - userPreferencesService.getCurrent(), - ) - val remoteTrailers = trailerService.getRemoteTrailers(movie) - val chapters = Chapter.fromDto(movie.data) - _state.update { - it.copy( - loading = DataLoadingState.Success(movie), - chosenStreams = chosenStreams, - trailers = remoteTrailers, - chapters = chapters, - ) + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val item = fetchAndSetItem().await() + val result = itemPlaybackRepository.getSelectedTracks(item.id, item) + withContext(Dispatchers.Main) { + this@MovieViewModel.item.value = item + chosenStreams.value = result + loading.value = LoadingState.Success } - backdropService.submit(movie) viewModelScope.launchIO { - trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers -> - _state.update { - it.copy( - trailers = localTrailers + remoteTrailers, - ) - } + val trailers = trailerService.getTrailers(item) + withContext(Dispatchers.Main) { + this@MovieViewModel.trailers.value = trailers } } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(movie) - _state.update { - it.copy( - people = people, - ) - } + val people = peopleFavorites.getPeopleFor(item) + this@MovieViewModel.people.setValueOnMain(people) } viewModelScope.launchIO { - val extras = extrasService.getExtras(itemId) - _state.update { - it.copy( - extras = extras, - ) - } - } - viewModelScope.launchIO { - val results = seerrService.similar(movie).orEmpty() - _state.update { - it.copy( - discovered = results, - ) - } + val extras = extrasService.getExtras(item.id) + this@MovieViewModel.extras.setValueOnMain(extras) } - if (state.value.similar.isEmpty()) { + withContext(Dispatchers.Main) { + chapters.value = Chapter.fromDto(item.data, api) + } + if (!similar.isInitialized) { val similar = api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, itemId = itemId, - fields = ItemRowFields, + fields = SlimItemFields, limit = 25, ), ).content.items - .map { BaseItem(it) } - - _state.update { it.copy(similar = similar) } + .map { BaseItem.Companion.from(it, api) } + this@MovieViewModel.similar.setValueOnMain(similar) } } fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launchDefault { - try { - favoriteWatchManager.setWatched(itemId, played) - getMovie().let { movie -> - _state.update { - it.copy(loading = DataLoadingState.Success(movie)) - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error updating watch status for movie %s", itemId) - showToast(context, "Something went wrong...") - } + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + fetchAndSetItem() } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launchDefault { - try { - favoriteWatchManager.setFavorite(itemId, favorite) - val movie = getMovie() - _state.update { - it.copy(loading = DataLoadingState.Success(movie)) + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + val item = item.value + fetchAndSetItem() + if (item != null && itemId != item.id) { + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(item) + this@MovieViewModel.people.setValueOnMain(people) } - if (itemId != movie.id) { - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(movie) - _state.update { it.copy(people = people) } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error updating favorite %s", itemId) - showToast(context, "Something went wrong...") } } @@ -222,13 +172,14 @@ class MovieViewModel sourceId: UUID, ) { viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result) } - _state.update { it.copy(chosenStreams = chosen) } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } } } @@ -239,7 +190,6 @@ class MovieViewModel type: MediaStreamType, ) { viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -247,14 +197,22 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) - _state.update { it.copy(chosenStreams = chosen) } + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) + } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } } } - fun maybePlayThemeSong(itemId: UUID) { + fun maybePlayThemeSong( + seriesId: UUID, + playThemeSongs: ThemeSongVolume, + ) { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(itemId) + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) addCloseable { themeSongPlayer.stop() } @@ -269,40 +227,4 @@ class MovieViewModel release() navigationManager.navigateTo(destination) } - - fun clearChosenStreams(chosenStreams: ChosenStreams?) { - viewModelScope.launchIO { - itemPlaybackRepository.deleteChosenStreams(chosenStreams) - state.value.movie?.let { item -> - val result = - itemPlaybackRepository.getSelectedTracks( - itemId, - item, - userPreferencesService.getCurrent(), - ) - _state.update { it.copy(chosenStreams = result) } - } - } - } - - fun deleteItem(item: BaseItem) { - deleteItem(context, mediaManagementService, item) { - navigationManager.goBack() - } - } } - -data class MovieState( - val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending, - val trailers: List<Trailer> = emptyList(), - val people: List<Person> = emptyList(), - val chapters: List<Chapter> = emptyList(), - val extras: List<ExtrasItem> = emptyList(), - val similar: List<BaseItem> = emptyList(), - val discovered: List<DiscoverItem> = emptyList(), - /** Seerr ratings, null until they load or if unavailable */ - val chosenStreams: ChosenStreams? = null, - val canDelete: Boolean = false, -) { - val movie: BaseItem? = (loading as? DataLoadingState.Success<BaseItem>)?.data -} 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 deleted file mode 100644 index 8f873bcd..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ /dev/null @@ -1,746 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import android.content.Context -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ExtrasItem -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -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.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.ItemRowFields -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle -import com.github.damontecres.wholphin.ui.cards.ExtrasRow -import com.github.damontecres.wholphin.ui.cards.ItemRow -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.GenreText -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.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -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.letNotEmpty -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.ui.tryRequestFocus -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.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.api.client.extensions.itemsApi -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.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest -import java.util.UUID - -@HiltViewModel(assistedFactory = AlbumViewModel.Factory::class) -class AlbumViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - navigationManager: NavigationManager, - mediaManagementService: MediaManagementService, - val serverRepository: ServerRepository, - 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") itemId: UUID, - @Assisted("initialSongId") private val initialSongId: UUID?, - ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { - @AssistedFactory - interface Factory { - fun create( - @Assisted("itemId") itemId: UUID, - @Assisted("initialSongId") initialSongId: UUID?, - ): AlbumViewModel - } - - private val _state = MutableStateFlow(AlbumState.EMPTY) - val state: StateFlow<AlbumState> = _state - - val currentMusic = musicService.state - - init { - init() - viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(state.map { it.album }) { canDelete -> - _state.update { it.copy(canDelete = canDelete) } - } - } - } - - override fun init() { - viewModelScope.launchIO { - try { - val itemDeferred = - async { - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - } - 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() + - album.data.albumArtists - ?.map { it.name } - .orEmpty() - val isVariousArtists = - allArtists.firstOrNull { it?.lowercase() == "various artists" } != null || - album.data.artists - .orEmpty() - .size > 1 - _state.update { - it.copy( - album = album, - isVariousArtists = isVariousArtists, - imageUrl = imageUrl, - songs = songs, - initialSongIndex = initialSongIndex, - loading = LoadingState.Success, - ) - } - updateBackDrop() - if (state.value.similar.isEmpty()) { - viewModelScope.launchIO { - val similar = - api.libraryApi - .getSimilarItems( - GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, - itemId = itemId, - excludeArtistIds = album.data.albumArtists?.map { it.id }, - fields = SlimItemFields, - limit = 25, - ), - ).content.items - .map { BaseItem.from(it, api) } - _state.update { it.copy(similar = similar) } - } - } - viewModelScope.launchIO { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - albumIds = 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) - } - } - } - viewModelScope.launchIO { - val extras = extrasService.getExtras(itemId) - if (extras.isNotEmpty()) { - _state.update { - it.copy(extras = extras) - } - } - } - } catch (ex: Exception) { - _state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun updateBackDrop() { - state.value.album?.let { album -> - viewModelScope.launchDefault { - getBackdropItemForAlbum(api, album)?.let { - backdropService.submit(it) - } - } - } - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - val album = - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - _state.update { it.copy(album = album) } - } - - fun play( - shuffled: Boolean, - startIndex: Int = 0, - ) { - val songs = state.value.songs as ApiRequestPager<*> - play(songs, startIndex, shuffled) - } - } - -suspend fun getBackdropItemForAlbum( - api: ApiClient, - album: BaseItem, -): BaseItem? { - if (album.data.backdropImageTags?.isNotEmpty() == true) { - return album - } else { - val artistIds = - album.data.albumArtists - ?.shuffled() - ?.take(50) - ?.map { it.id } - return api.itemsApi - .getItems( - ids = artistIds, - imageTypes = listOf(ImageType.BACKDROP), - ).content.items - .firstOrNull() - ?.let { BaseItem(it, false) } - } -} - -data class AlbumState( - val album: BaseItem?, - val isVariousArtists: Boolean, - val imageUrl: String?, - 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, -) { - companion object { - val EMPTY = AlbumState(null, false, null, emptyList(), emptyList(), LoadingState.Pending) - } -} - -private const val HEADER_ROW = 0 -private const val SONG_ROW = HEADER_ROW + 1 -private const val MUSIC_VIDEO_ROW = SONG_ROW + 1 -private const val EXTRAS_ROW = SONG_ROW + 1 -private const val SIMILAR_ROW = EXTRAS_ROW + 1 - -@Composable -fun AlbumDetailsPage( - itemId: UUID, - preferences: UserPreferences, - modifier: Modifier = Modifier, - initialSongId: UUID? = null, - viewModel: AlbumViewModel = - hiltViewModel<AlbumViewModel, AlbumViewModel.Factory>( - creationCallback = { it.create(itemId, initialSongId) }, - ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val state by viewModel.state.collectAsState() - val currentMusic by viewModel.currentMusic.collectAsState() - - val focusRequesters = - remember { List(SIMILAR_ROW + 1) { FocusRequester() } } - val focusManager = LocalFocusManager.current - 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 = { index, _ -> viewModel.play(false, index) }, - onClickPlayNext = { _, song -> viewModel.playNext(song) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, - onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - ) - } - when (val loading = state.loading) { - is LoadingState.Error -> { - ErrorMessage(loading, modifier) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - 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( - initialFirstVisibleItemIndex = initialSongIndex?.let { itemsBefore + it } ?: 0, - ) - - val songFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (position.row == SONG_ROW) { - songFocusRequester.tryRequestFocus() - } else { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - viewModel.updateBackDrop() - } - val backHandlerActive by remember { - derivedStateOf { - listState.firstVisibleItemIndex > itemsBefore - } - } - BackHandler(backHandlerActive) { - scope.launch { - listState.animateScrollToItem(itemsBefore) - firstBringIntoViewRequester.bringIntoView() - firstFocusRequester.tryRequestFocus() - } - } - Box(modifier = modifier) { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.fillMaxSize(), - ) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester) - .padding(bottom = 32.dp), - ) { - AlbumHeader( - album = album, - imageUrl = state.imageUrl, - overviewOnClick = {}, - bringIntoViewRequester = bringIntoViewRequester, - modifier = Modifier.fillMaxWidth(), - ) - MusicExpandableButtons( - title = album.title ?: "", - actions = - remember { - MusicButtonActions( - onClickPlay = { viewModel.play(it, 0) }, - onClickInstantMix = { viewModel.startInstantMix(album.id) }, - onClickFavorite = { - viewModel.setFavorite( - album.id, - !album.favorite, - ) - }, - onClickMore = { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = false, - item = album, - index = 0, - canDelete = - viewModel.canDelete( - album, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - }, - onConfirmDelete = { - viewModel.deleteItem(album) - }, - ) - }, - favorite = album.favorite, - canDelete = state.canDelete, - buttonOnFocusChanged = { - if (it.isFocused) { - position = RowColumn(HEADER_ROW, 0) - scope.launch { bringIntoViewRequester.bringIntoView() } - } - }, - modifier = - Modifier - .onFocusChanged { - if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } - }.focusRequester(focusRequesters[HEADER_ROW]), - ) - } - } - item { - Text( - text = stringResource(R.string.songs) + " (${state.songs.size})", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), - ) - } - itemsIndexed(state.songs) { index, song -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - SongListItem( - song = song, - onClick = { - position = RowColumn(SONG_ROW, index) - viewModel.play(false, index) - }, - onLongClick = { - if (song != null) { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, - item = song, - index = index, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - } - }, - onClickMore = { - if (song != null) { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = false, - item = song, - index = index, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - } - }, - showArtist = state.isVariousArtists, - isPlaying = song != null && currentMusic.currentItemId == song.id, - isQueued = song != null && song.id in currentMusic.queuedIds, - modifier = - Modifier - .fillMaxWidth(.75f) - .ifElse( - index == 0, - Modifier - .focusRequester(firstFocusRequester) - .bringIntoViewRequester(firstBringIntoViewRequester), - ).ifElse( - position.row == SONG_ROW && position.column == index, - Modifier.focusRequester(songFocusRequester), - ), - ) - } - } - if (state.musicVideos.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.music_videos), - items = state.musicVideos, - onClickItem = { index, item -> - position = RowColumn(MUSIC_VIDEO_ROW, index) - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { index, item -> - // TODO - }, - cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = item?.name, - subtitle = item?.data?.productionYear?.toString(), - item = item, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, - played = item?.played ?: false, - playPercent = item?.data?.userData?.playedPercentage ?: 0.0, - favorite = item?.favorite ?: false, - modifier = mod, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), - ) - } - } - if (state.extras.isNotEmpty()) { - item { - ExtrasRow( - extras = state.extras, - onClickItem = { index, extra -> - position = RowColumn(EXTRAS_ROW, index) - viewModel.navigationManager.navigateTo(extra.destination) - }, - onLongClickItem = { _, _ -> }, - modifier = Modifier.focusRequester(focusRequesters[EXTRAS_ROW]), - ) - } - } - if (state.similar.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.more_like_this), - items = state.similar, - onClickItem = { index, item -> - position = RowColumn(SIMILAR_ROW, index) - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { index, item -> - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, - item = item, - index = index, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - showGoTo = true, - ) - }, - cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = item?.name, - subtitle = item?.data?.productionYear?.toString(), - item = item, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = AspectRatios.SQUARE, - modifier = mod, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), - ) - } - } - } - } - } - } - 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() - }, - onSearch = playlistViewModel::loadPlaylists, - elevation = 3.dp, - ) - } -} - -@Composable -fun AlbumHeader( - album: BaseItem, - imageUrl: String?, - overviewOnClick: () -> Unit, - bringIntoViewRequester: BringIntoViewRequester, - modifier: Modifier, -) { - val scope = rememberCoroutineScope() - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.padding(top = 32.dp), - ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth(.20f) - .clip(RoundedCornerShape(16.dp)), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - // Artist - Text( - text = album.artistsString ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = 8.dp), - ) - Text( - text = album.name ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = 8.dp), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(.60f), - ) { - QuickDetails( - album.ui.quickDetails, - null, - Modifier.padding(start = 8.dp), - ) - - album.data.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp)) - } - - // Description - album.data.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } - } - } - } -} 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 deleted file mode 100644 index 20a39e0f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ /dev/null @@ -1,843 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import android.content.Context -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.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -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.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.services.ServerReportService -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.AspectRatios -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 -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.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -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.letNotEmpty -import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.ui.tryRequestFocus -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.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.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.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest -import java.util.UUID - -@HiltViewModel(assistedFactory = ArtistViewModel.Factory::class) -class ArtistViewModel - @AssistedInject - constructor( - @ApplicationContext context: Context, - api: ApiClient, - musicService: MusicService, - navigationManager: NavigationManager, - mediaManagementService: MediaManagementService, - val serverRepository: ServerRepository, - val serverReportService: ServerReportService, - private val favoriteWatchManager: FavoriteWatchManager, - private val userPreferencesService: UserPreferencesService, - private val backdropService: BackdropService, - private val imageUrlService: ImageUrlService, - @Assisted itemId: UUID, - ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { - @AssistedFactory - interface Factory { - fun create(itemId: UUID): ArtistViewModel - } - - private val _state = MutableStateFlow(ArtistState.EMPTY) - val state: StateFlow<ArtistState> = _state - - val currentMusic = musicService.state - - init { - init() - viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(state.map { it.artist }) { canDelete -> - _state.update { it.copy(canDelete = canDelete) } - } - } - } - - override fun init() { - viewModelScope.launchIO { - try { - val itemDeferred = - async { - api.userLibraryApi - .getItem(itemId = itemId) - .content - .let { BaseItem(it, false) } - } - val albumsDeferred = - async { - val request = - GetItemsRequest( - albumArtistIds = 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 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, - ) - } - - viewModelScope.launchIO { - val request = - GetItemsRequest( - artistIds = listOf(itemId), - fields = SlimItemFields, - recursive = true, - includeItemTypes = listOf(BaseItemKind.AUDIO), - minCommunityRating = 1.0, - sortBy = - listOf( - ItemSortBy.COMMUNITY_RATING, - ), - sortOrder = listOf(SortOrder.DESCENDING), - limit = 10, - ) - val topSongs = - GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - if (topSongs.isNotEmpty()) { - _state.update { - it.copy(topSongs = topSongs) - } - } - } - if (state.value.similar.isEmpty()) { - viewModelScope.launchIO { - val similar = - api.libraryApi - .getSimilarItems( - GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, - itemId = itemId, - excludeArtistIds = listOf(itemId), - fields = SlimItemFields, - limit = 25, - ), - ).content.items - .map { BaseItem.from(it, api) } - _state.update { it.copy(similar = similar) } - } - } - updateMusicVideos() - } catch (ex: Exception) { - _state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun refresh() { - viewModelScope.launchDefault { - state.value.artist?.let { - backdropService.submit(it) - } - } - } - - 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() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - // 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() - } - } - -data class ArtistState( - val artist: BaseItem?, - 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?>, - val canDelete: Boolean, -) { - companion object { - val EMPTY = - ArtistState( - null, - null, - emptyList(), - emptyList(), - emptyList(), - emptyList(), - LoadingState.Pending, - emptyList(), - false, - ) - } -} - -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 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 -fun ArtistDetailsPage( - preferences: UserPreferences, - itemId: UUID, - modifier: Modifier = Modifier, - viewModel: ArtistViewModel = - hiltViewModel<ArtistViewModel, ArtistViewModel.Factory>( - creationCallback = { it.create(itemId) }, - ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - 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() } } - - 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 = { index, item -> viewModel.play(item) }, - onClickPlayNext = { _, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, -1) }, - onClickFavorite = { itemId, favorite -> viewModel.setFavorite(itemId, favorite) }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - ) - } - - when (val loading = state.loading) { - is LoadingState.Error -> { - ErrorMessage(loading, modifier) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - val artist = state.artist!! - val songFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (position.row == SONG_ROW) { - songFocusRequester.tryRequestFocus() - } else { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - viewModel.refresh() - } - val bringIntoViewRequester = remember { BringIntoViewRequester() } - Box(modifier = modifier) { - LazyColumn( - contentPadding = PaddingValues(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .fillMaxSize(), - ) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth() - .bringIntoViewRequester(bringIntoViewRequester) - .padding(bottom = 16.dp), - ) { - ArtistHeader( - artist = artist, - imageUrl = state.imageUrl, - overviewOnClick = {}, - bringIntoViewRequester = bringIntoViewRequester, - modifier = Modifier.fillMaxWidth(), - ) - MusicExpandableButtons( - title = artist.title ?: "", - actions = - remember { - MusicButtonActions( - onClickPlay = { shuffled -> - viewModel.play(artist, shuffled = shuffled) - }, - onClickInstantMix = { viewModel.startInstantMix(artist.id) }, - onClickFavorite = { - viewModel.setFavorite( - artist.id, - !artist.favorite, - ) - }, - onClickMore = { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = false, - item = artist, - index = 0, - canDelete = - viewModel.canDelete( - artist, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - }, - onConfirmDelete = { viewModel.deleteItem(artist) }, - ) - }, - favorite = artist.favorite, - canDelete = state.canDelete, - buttonOnFocusChanged = { - if (it.isFocused) { - position = RowColumn(HEADER_ROW, 0) - scope.launch { bringIntoViewRequester.bringIntoView() } - } - }, - modifier = - Modifier - .onFocusChanged { - if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } - }.focusRequester(focusRequesters[HEADER_ROW]), - ) - } - } - if (state.topSongs.isNotEmpty()) { - item { - Text( - text = stringResource(R.string.popular_songs), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), - ) - } - itemsIndexed(state.topSongs) { index, song -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - SongListItem( - song = song, - onClick = { - position = RowColumn(SONG_ROW, index) - song?.let { viewModel.play(it) } - }, - onLongClick = { - if (song != null) { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, - item = song, - index = index, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - } - }, - onClickMore = { - if (song != null) { - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = false, - item = song, - index = index, - canDelete = - viewModel.canDelete( - song, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - ) - } - }, - showArtist = false, - isPlaying = song != null && currentMusic.currentItemId == song.id, - isQueued = song != null && song.id in currentMusic.queuedIds, - modifier = - Modifier - .fillMaxWidth(.75f) - .ifElse( - position.row == SONG_ROW && position.column == index, - Modifier.focusRequester(songFocusRequester), - ), - ) - } - } - } - 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, - showGoTo = true, - ) - }, - 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.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, - showGoTo = true, - ) - }, - 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 { - ItemRow( - title = stringResource(R.string.music_videos), - items = state.musicVideos, - onClickItem = { index, item -> - position = RowColumn(MUSIC_VIDEO_ROW, index) - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { index, item -> - 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( - title = item?.name, - subtitle = item?.data?.productionYear?.toString(), - item = item, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = item?.aspectRatio ?: AspectRatios.WIDE, - played = item?.played ?: false, - playPercent = item?.data?.userData?.playedPercentage ?: 0.0, - favorite = item?.favorite ?: false, - modifier = mod, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]), - ) - } - } - if (state.similar.isNotEmpty()) { - item { - ItemRow( - title = stringResource(R.string.more_like_this), - items = state.similar, - onClickItem = { index, item -> - position = RowColumn(SIMILAR_ROW, index) - viewModel.navigationManager.navigateTo(item.destination()) - }, - onLongClickItem = { index, item -> - showContextMenu = - ContextMenu.ForMusic( - fromLongClick = true, - item = item, - index = index, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), - canRemoveFromQueue = false, - actions = moreDialogActions, - showGoTo = true, - ) - }, - cardContent = { index: Int, item: BaseItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - BannerCardWithTitle( - title = item?.name, - subtitle = item?.data?.productionYear?.toString(), - item = item, - onClick = onClick, - onLongClick = onLongClick, - aspectRatio = AspectRatios.SQUARE, - modifier = mod, - ) - }, - modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]), - ) - } - } - } - } - } - } - 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() - }, - onSearch = playlistViewModel::loadPlaylists, - elevation = 3.dp, - ) - } -} - -@Composable -fun ArtistHeader( - artist: BaseItem, - imageUrl: String?, - overviewOnClick: () -> Unit, - bringIntoViewRequester: BringIntoViewRequester, - modifier: Modifier, -) { - val scope = rememberCoroutineScope() - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.padding(top = 32.dp), - ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = - Modifier - .fillMaxWidth(.20f) - .clip(RoundedCornerShape(16.dp)), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - // Artist - Text( - text = artist.artistsString ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = 8.dp), - ) - Text( - text = artist.name ?: "", - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = 8.dp), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(.60f), - ) { - QuickDetails( - artist.ui.quickDetails, - null, - Modifier.padding(start = 8.dp), - ) - - artist.data.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp)) - } - - // Description - artist.data.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt deleted file mode 100644 index d5ea2037..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/BarVisualizer.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.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.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme - -@Composable -fun BarVisualizer( - data: IntArray, - modifier: Modifier, -) { - var size by remember { mutableStateOf(IntSize.Zero) } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.onSizeChanged { size = it }, - ) { - val size = with(LocalDensity.current) { DpSize(size.width.toDp(), size.height.toDp()) } - val padding = 1.dp - val width = size.width / data.size - - data.forEachIndexed { index, data -> - val height by animateDpAsState( - targetValue = size.height * data / 256f, - animationSpec = tween(easing = LinearEasing), - ) - Box( - Modifier - .height(height) - .width(width) - .padding(start = if (index == 0) 0.dp else padding) - .background(MaterialTheme.colorScheme.border), - ) - } - } -} 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 deleted file mode 100644 index e60e2a08..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/LyricsContent.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -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.fillMaxSize -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.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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 -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll -import org.jellyfin.sdk.model.api.LyricDto -import org.jellyfin.sdk.model.api.LyricLine - -@Composable -fun LyricsContent( - lyricsHaveFocus: Boolean, - lyrics: LyricDto?, - currentLyricPosition: Int?, - onClick: (LyricLine) -> Unit, - onFocusLyrics: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - val focusRequesters = - remember(lyrics) { List(lyrics?.lyrics.orEmpty().size) { FocusRequester() } } - val listState = rememberLazyListState(currentLyricPosition ?: 0) - - val scrollConnection = rememberDelayedNestedScroll(yDelay = .66f) - - val bringIntoViewRequester = remember { BringIntoViewRequester() } - if (!lyricsHaveFocus) { - LaunchedEffect(currentLyricPosition) { - if (currentLyricPosition != null) { - listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { - if (currentLyricPosition >= 0 && currentLyricPosition !in listState.firstVisibleItemIndex..it) { - listState.animateScrollToItem(currentLyricPosition) - } - } - bringIntoViewRequester.bringIntoView() - } - } - } - Column( - modifier - .nestedScroll(scrollConnection), - ) { - LazyColumn( - state = listState, - contentPadding = PaddingValues(), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .fillMaxSize() - .focusProperties { - onEnter = { - if (currentLyricPosition != null) { - currentLyricPosition.let { - focusRequesters - .getOrNull(currentLyricPosition) - ?.tryRequestFocus() - } - } else { - focusRequesters.getOrNull(0)?.tryRequestFocus() - } - } - }.onFocusChanged { - onFocusLyrics.invoke(it.hasFocus) - }, - ) { - if (lyrics?.lyrics?.isNotEmpty() == true) { - itemsIndexed(lyrics.lyrics) { index, lyric -> - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - val color by animateColorAsState( - if (index == currentLyricPosition || currentLyricPosition == null) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = .4f) - }, - animationSpec = tween(durationMillis = 500, easing = LinearEasing), - ) - Surface( - onClick = { onClick.invoke(lyric) }, - interactionSource = interactionSource, - colors = - ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = MaterialTheme.colorScheme.border.copy(alpha = .33f), - ), - shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)), - scale = - ClickableSurfaceDefaults.scale( - focusedScale = 1f, - pressedScale = .9f, - ), - modifier = - Modifier - .focusRequester(focusRequesters[index]) - .onFocusChanged { - if (it.isFocused) onFocusLyrics.invoke(true) - }, - ) { - val text = - remember(lyric.text) { lyric.text.ifBlank { " " } } - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = if (focused) MaterialTheme.colorScheme.onSurface else color, - modifier = - Modifier - .padding(8.dp) - .ifElse( - index == currentLyricPosition, - Modifier.bringIntoViewRequester(bringIntoViewRequester), - ), - ) - } - } - } - } - } -} 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 deleted file mode 100644 index 96e82032..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicExpandableButtons.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -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.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.DeleteButton -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import kotlin.time.Duration - -@Composable -fun MusicExpandableButtons( - title: String, - actions: MusicButtonActions, - favorite: Boolean, - canDelete: Boolean, - buttonOnFocusChanged: (FocusState) -> Unit, - modifier: Modifier = Modifier, - showShuffle: Boolean = true, -) { - val firstFocus = remember { FocusRequester() } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .focusGroup() - .focusRestorer(firstFocus), - ) { - item("play") { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { actions.onClickPlay.invoke(false) }, - modifier = - Modifier - .focusRequester(firstFocus) - .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( - title = R.string.instant_mix, - iconStringRes = R.string.fa_compass, - onClick = actions.onClickInstantMix, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - item("favorite") { - ExpandableFaButton( - title = if (favorite) R.string.remove_favorite else R.string.add_favorite, - iconStringRes = R.string.fa_heart, - onClick = actions.onClickFavorite, - iconColor = if (favorite) Color.Red else Color.Unspecified, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - if (canDelete) { - item("delete") { - DeleteButton( - title = title, - onConfirmDelete = actions.onConfirmDelete, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - } - item("more") { - ExpandablePlayButton( - title = R.string.more, - resume = Duration.ZERO, - icon = Icons.Default.MoreVert, - onClick = { actions.onClickMore.invoke() }, - modifier = Modifier.onFocusChanged(buttonOnFocusChanged), - ) - } - } -} - -data class MusicButtonActions( - val onClickPlay: (shuffle: Boolean) -> Unit, - val onClickInstantMix: () -> Unit, - val onClickFavorite: () -> Unit, - val onClickMore: () -> Unit, - val onConfirmDelete: () -> Unit, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt deleted file mode 100644 index 1a519998..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicPreference.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.AppSwitchPreference -import com.github.damontecres.wholphin.preferences.updateMusicPreferences - -fun getMusicPreferences() = - listOf( - AppSwitchPreference<AppPreferences>( - title = R.string.show_album_cover, - defaultValue = false, - getter = { it.musicPreferences.showAlbumArt }, - setter = { prefs, value -> - prefs.updateMusicPreferences { showAlbumArt = value } - }, - ), - AppSwitchPreference<AppPreferences>( - title = R.string.show_visualizer, - defaultValue = false, - getter = { it.musicPreferences.showVisualizer }, - setter = { prefs, value -> - prefs.updateMusicPreferences { showVisualizer = value } - }, - ), - AppSwitchPreference<AppPreferences>( - title = R.string.show_backdrop, - defaultValue = false, - getter = { it.musicPreferences.showBackdrop }, - setter = { prefs, value -> - prefs.updateMusicPreferences { showBackdrop = value } - }, - ), - AppSwitchPreference<AppPreferences>( - title = R.string.show_lyrics, - defaultValue = false, - getter = { it.musicPreferences.showLyrics }, - setter = { prefs, value -> - prefs.updateMusicPreferences { showLyrics = value } - }, - ), - ) 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 deleted file mode 100644 index 87062cf2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewModel.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.AppPreferences -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.services.deleteItem -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.util.ApiRequestPager -import com.github.damontecres.wholphin.util.BlockingList -import kotlinx.coroutines.delay -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber -import java.util.UUID - -abstract class MusicViewModel( - internal val itemId: UUID, - internal val context: Context, - internal val api: ApiClient, - internal val musicService: MusicService, - internal val navigationManager: NavigationManager, - internal val mediaManagementService: MediaManagementService, -) : ViewModel() { - fun play( - pager: ApiRequestPager<*>, - startIndex: Int = 0, - shuffled: Boolean = false, - ) { - viewModelScope.launchIO { - musicService.setQueue(pager, startIndex, shuffled) - } - } - - fun play( - item: BaseItem, - startIndex: Int = 0, - shuffled: Boolean = false, - ) { - viewModelScope.launchIO { - Timber.v("Playing %s %s", item.type, item.id) - when (item.type) { - BaseItemKind.AUDIO -> { - musicService.setQueue(listOf(item), shuffled) - } - - BaseItemKind.MUSIC_ALBUM -> { - val pager = getPagerForAlbum(api, item.id) - musicService.setQueue(pager, startIndex, shuffled) - } - - BaseItemKind.MUSIC_ARTIST -> { - val pager = getPagerForArtist(api, item.id) - musicService.setQueue(pager, startIndex, shuffled) - } - - BaseItemKind.PLAYLIST -> { - val pager = getPagerForPlaylist(api, item.id) - musicService.setQueue(pager, startIndex, shuffled) - } - - else -> { - Timber.w("Unknown item type to play for music: %s", item.type) - } - } - } - } - - fun playNext(song: BaseItem) { - viewModelScope.launchDefault { - musicService.playNext(song) - } - } - - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) - - fun startInstantMix(itemId: UUID) { - viewModelScope.launchIO { - Timber.v("Starting instant mix for %s", itemId) - musicService.startInstantMix(itemId) - } - viewModelScope.launchDefault { - // TODO better way to wait for query above to start - delay(250) - navigationManager.navigateTo(Destination.NowPlaying) - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun deleteItem(item: BaseItem) { - deleteItem(context, mediaManagementService, item) { - if (item.id == itemId) { - navigationManager.goBack() - } else { - init() - } - } - } - - internal abstract fun init() -} - -fun ViewModel.addToQueue( - api: ApiClient, - musicService: MusicService, - item: BaseItem, - index: Int, -) { - viewModelScope.launchIO { - Timber.v("addToQueue %s %s", item.type, item.id) - when (item.type) { - BaseItemKind.AUDIO -> { - musicService.addAllToQueue(BlockingList.of(listOf(item)), 0) - } - - BaseItemKind.MUSIC_ALBUM -> { - val pager = getPagerForAlbum(api, item.id) - musicService.addAllToQueue(pager, 0) - } - - BaseItemKind.MUSIC_ARTIST -> { - val pager = getPagerForArtist(api, item.id) - musicService.addAllToQueue(pager, 0) - } - - BaseItemKind.PLAYLIST -> { - val pager = getPagerForPlaylist(api, item.id) - musicService.addAllToQueue(pager, 0) - } - - else -> { - Timber.w("Unknown item type to queue for music: %s", item.type) - } - } - } -} - -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/MusicViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt deleted file mode 100644 index cf9f9c7c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/MusicViewOptionsDialog.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -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.heightIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.tryRequestFocus - -@Composable -fun MusicViewOptionsDialog( - appPreferences: AppPreferences, - onDismissRequest: () -> Unit, - onViewOptionsChange: (AppPreferences) -> Unit, - onEnableVisualizer: () -> Unit, -) { - val context = LocalContext.current - - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val columnState = rememberLazyListState() - val items = remember { getMusicPreferences() } - - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.END) - window.setDimAmount(0f) - } - - Column { - Text( - text = stringResource(R.string.view_options), - style = MaterialTheme.typography.titleMedium, - ) - } - LazyColumn( - state = columnState, - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = - Modifier - .width(256.dp) - .heightIn(max = 380.dp) - .focusRequester(focusRequester) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - shape = RoundedCornerShape(8.dp), - ), - ) { - items(items) { pref -> - pref as AppPreference<AppPreferences, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(appPreferences) - // Using title is a bit hacky - when (pref.title) { - R.string.show_visualizer -> { - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - newValue as Boolean - if (newValue) { - onEnableVisualizer.invoke() - } else { - onViewOptionsChange.invoke( - pref.setter( - appPreferences, - newValue, - ), - ) - } - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = { pref -> - }, - ) - } - - else -> { - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(appPreferences, newValue)) - }, - interactionSource = interactionSource, - modifier = Modifier, - onClickPreference = { pref -> - }, - ) - } - } - } - } - } -} 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 deleted file mode 100644 index f8dc0ccd..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingButtons.kt +++ /dev/null @@ -1,315 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.annotation.OptIn -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.onFocusChanged -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.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.compose.state.rememberNextButtonState -import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import androidx.media3.ui.compose.state.rememberPreviousButtonState -import androidx.media3.ui.compose.state.rememberRepeatButtonState -import androidx.media3.ui.compose.state.rememberShuffleButtonState -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Button -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.buttonSpacing -import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import kotlin.time.Duration.Companion.seconds - -@OptIn(UnstableApi::class) -@Composable -fun NowPlayingButtons( - player: Player, - controllerViewState: ControllerViewState, - initialFocusRequester: FocusRequester, - onClickMore: () -> Unit, - onClickStop: () -> Unit, - modifier: Modifier = Modifier, -) { - val playPauseState = rememberPlayPauseButtonState(player) - val previousState = rememberPreviousButtonState(player) - val nextState = rememberNextButtonState(player) - val shuffleState = rememberShuffleButtonState(player) - val repeatState = rememberRepeatButtonState(player) - - val onControllerInteraction = remember { { controllerViewState.pulseControls() } } - Box( - modifier = modifier.focusGroup(), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.align(Alignment.CenterStart), - ) { - PlaybackButton( - iconRes = R.drawable.vector_settings, - onClick = { - onControllerInteraction.invoke() - onClickMore.invoke() - }, - enabled = true, - onControllerInteraction = onControllerInteraction, - modifier = Modifier, - ) - PlaybackButton( - iconRes = R.drawable.baseline_stop_24, - onClick = { - onClickStop.invoke() - }, - enabled = true, - onControllerInteraction = onControllerInteraction, - modifier = Modifier, - ) - } - PlaybackButtons( - player = player, - initialFocusRequester = initialFocusRequester, - onControllerInteraction = onControllerInteraction, - onPlaybackActionClick = { - when (it) { - PlaybackAction.Next -> { - nextState.onClick() - } - - PlaybackAction.Previous -> { - previousState.onClick() - } - - is PlaybackAction.ToggleCaptions -> { - TODO() - } - - else -> {} - } - }, - showPlay = playPauseState.showPlay, - previousEnabled = previousState.isEnabled, - nextEnabled = nextState.isEnabled, - seekBack = 10.seconds, - skipBackOnResume = null, - seekForward = 30.seconds, // TODO - modifier = Modifier.align(Alignment.Center), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.align(Alignment.CenterEnd), - ) { - ShuffleButton( - active = shuffleState.shuffleOn, - enabled = shuffleState.isEnabled, - onClick = { - shuffleState.onClick() - }, - onControllerInteraction = onControllerInteraction, - ) - RepeatButton( - repeatMode = repeatState.repeatModeState, - enabled = repeatState.isEnabled, - onClick = { - repeatState.onClick() - }, - onControllerInteraction = onControllerInteraction, - ) - } - } -} - -@Composable -fun ShuffleButton( - active: Boolean, - onClick: () -> Unit, - onControllerInteraction: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val selectedColor = MaterialTheme.colorScheme.border - val focused by interactionSource.collectIsFocusedAsState() - Button( - enabled = enabled, - onClick = onClick, -// shape = ButtonDefaults.shape(CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = AppColors.TransparentBlack25, - focusedContainerColor = selectedColor, - ), - contentPadding = PaddingValues(4.dp), - interactionSource = interactionSource, - modifier = - modifier - .size(36.dp, 36.dp) - .onFocusChanged { onControllerInteraction.invoke() }, - ) { - Text( - text = stringResource(R.string.fa_shuffle), - fontSize = 18.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - color = - when { - focused && active -> MaterialTheme.colorScheme.onSurface - focused && !active -> MaterialTheme.colorScheme.surface - !focused && active -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) - }, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.CenterVertically), - ) - } -} - -@Composable -fun RepeatButton( - repeatMode: Int, - onClick: () -> Unit, - onControllerInteraction: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val selectedColor = MaterialTheme.colorScheme.border - val focused by interactionSource.collectIsFocusedAsState() - Button( - enabled = enabled, - onClick = onClick, -// shape = ButtonDefaults.shape(CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = AppColors.TransparentBlack25, - focusedContainerColor = selectedColor, - ), - contentPadding = PaddingValues(4.dp), - interactionSource = interactionSource, - modifier = - modifier - .size(36.dp, 36.dp) - .onFocusChanged { onControllerInteraction.invoke() }, - ) { - Box( - Modifier - .fillMaxWidth() - .align(Alignment.CenterVertically), - ) { - Text( - text = stringResource(R.string.fa_repeat), - fontSize = 18.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - color = - if (focused) { - when (repeatMode) { - Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface - Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.surface - } - } else { - when (repeatMode) { - Player.REPEAT_MODE_ALL -> MaterialTheme.colorScheme.onSurface - Player.REPEAT_MODE_ONE -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) - } - }, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.Center), - ) - if (repeatMode == Player.REPEAT_MODE_ONE) { - Text( - text = "1", - fontSize = 10.sp, - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .offset(x = 4.dp) - .background( - color = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(1.dp), - ).align(Alignment.BottomStart), - ) - } - } - } -} - -@PreviewTvSpec -@Composable -fun ShuffleButtonPreview() { - val source = remember { PreviewInteractionSource() } - WholphinTheme { - Column { - Row { - RepeatButton( - repeatMode = Player.REPEAT_MODE_OFF, - onClick = {}, - onControllerInteraction = {}, - ) - RepeatButton( - repeatMode = Player.REPEAT_MODE_ALL, - onClick = {}, - onControllerInteraction = {}, - ) - RepeatButton( - repeatMode = Player.REPEAT_MODE_ONE, - onClick = {}, - onControllerInteraction = {}, - ) - } - Row { - RepeatButton( - repeatMode = Player.REPEAT_MODE_OFF, - onClick = {}, - onControllerInteraction = {}, - interactionSource = source, - ) - RepeatButton( - repeatMode = Player.REPEAT_MODE_ALL, - onClick = {}, - onControllerInteraction = {}, - interactionSource = source, - ) - RepeatButton( - repeatMode = Player.REPEAT_MODE_ONE, - onClick = {}, - onControllerInteraction = {}, - interactionSource = source, - ) - } - } - } -} 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 deleted file mode 100644 index 8d12bb33..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingOverlay.kt +++ /dev/null @@ -1,259 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.activity.compose.BackHandler -import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -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.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.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 -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.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 -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.tv.material3.Icon -import androidx.tv.material3.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 -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.playback.overlay.SeekBar -import com.github.damontecres.wholphin.ui.preferences.MoveButton -import com.github.damontecres.wholphin.ui.roundSeconds -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.coroutines.launch -import kotlin.time.Duration - -@OptIn(UnstableApi::class) -@Composable -fun NowPlayingOverlay( - state: NowPlayingState, - player: Player, - current: AudioItem?, - controllerViewState: ControllerViewState, - onClickSong: (Int, AudioItem) -> Unit, - onLongClickSong: (Int, AudioItem) -> Unit, - onClickMore: () -> Unit, - onMoveQueue: (Int, MoveDirection) -> Unit, - onClickMoreItem: (Int, AudioItem) -> Unit, - onClickStop: () -> Unit, - lyricsFocusRequester: FocusRequester, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - val focusRequester = remember { FocusRequester() } - val currentItemFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - - var queueHasFocus by remember { mutableStateOf(false) } - val height by animateFloatAsState( - if (queueHasFocus) { - 1f - } else { - .33f - }, - animationSpec = tween(durationMillis = 500), - ) - val listState = - rememberLazyListState( - initialFirstVisibleItemIndex = state.musicServiceState.currentIndex, - ) - var showButtons by remember { mutableStateOf(true) } - - val firstFocusRequester = remember { FocusRequester() } - BackHandler(!showButtons) { - scope.launch { - listState.scrollToItem(0) - firstFocusRequester.tryRequestFocus() - } - } - - Column( - modifier = - modifier - .padding(16.dp) - .fillMaxHeight(height), - ) { - SeekBar( - player = player, - controllerViewState = controllerViewState, - onSeekProgress = {}, - interactionSource = remember { MutableInteractionSource() }, - isEnabled = false, - intervals = 0, - seekBack = Duration.ZERO, - seekForward = Duration.ZERO, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .padding(top = 8.dp) - .fillMaxWidth(.95f), - ) - AnimatedVisibility( - visible = showButtons, - enter = expandVertically(), - exit = shrinkVertically(), - modifier = - Modifier - .align(Alignment.CenterHorizontally), - ) { - NowPlayingButtons( - player = player, - controllerViewState = controllerViewState, - initialFocusRequester = focusRequester, - onClickMore = onClickMore, - onClickStop = onClickStop, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - ) - } - val queue = - rememberQueue( - player, - state.musicServiceState.queueVersion, - state.musicServiceState.queueSize, - ) - if (queue.isEmpty()) { - Text("No items") - } else { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp), - ) - LazyColumn( - state = listState, - contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), - modifier = - Modifier - .fillMaxSize() - .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 = - Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = .5f), - shape = RoundedCornerShape(8.dp), - ).padding(end = 8.dp) - .onFocusChanged { - if (it.hasFocus) showButtons = index < 3 - controllerViewState.pulseControls() - }.animateItem() - .bringIntoViewRequester(bringIntoViewRequester), - ) { - SongListItem( - title = song.title, - artist = song.artistNames, - indexNumber = index + 1, - runtime = song.runtime?.roundSeconds, - showArtist = true, - isPlaying = current?.id == song.id, - onClick = { onClickSong.invoke(index, song) }, - onLongClick = { onLongClickSong.invoke(index, song) }, - modifier = - Modifier - .weight(1f) - .ifElse( - index == 0, - Modifier.focusRequester(firstFocusRequester), - ).ifElse( - index == state.musicServiceState.currentIndex, - Modifier.focusRequester(currentItemFocusRequester), - ), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.wrapContentWidth(), - ) { - MoveButton( - icon = R.string.fa_caret_up, - enabled = index > 0, - 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) - scope.launch { - bringIntoViewRequester.bringIntoView() - } - }, - ) - Button( - onClick = { onClickMoreItem.invoke(index, song) }, - enabled = true, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - ) - } - } - } - } - } - } - } -} 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 deleted file mode 100644 index 5b22e6d2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ /dev/null @@ -1,489 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import android.Manifest -import android.view.Gravity -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -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.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -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.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.media3.common.Player -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 -import coil3.compose.AsyncImage -import com.github.damontecres.wholphin.R -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.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.ContextMenu -import com.github.damontecres.wholphin.ui.components.ContextMenuDialog -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.QueueContextActions -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.nav.Backdrop -import com.github.damontecres.wholphin.ui.playback.PlaybackKeyHandler -import com.github.damontecres.wholphin.ui.playback.isUp -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) -@Composable -fun NowPlayingPage( - modifier: Modifier = Modifier, - viewModel: NowPlayingViewModel = - hiltViewModel<NowPlayingViewModel, NowPlayingViewModel.Factory>( - creationCallback = { it.create() }, - ), -) { - val context = LocalContext.current - val state by viewModel.state.collectAsState() - val player = viewModel.player - val currentMediaItem = rememberCurrentMediaItemState(player) - val current = (currentMediaItem.mediaItem?.localConfiguration?.tag as? AudioItem) - val viz by viewModel.viz.collectAsState() - - val controllerViewState = viewModel.controllerViewState - val preferences = - viewModel.userPreferencesService.flow - .collectAsState( - UserPreferences( - AppPreferences.getDefaultInstance(), - null, - ), - ).value.appPreferences - val musicPrefs = preferences.musicPreferences - - val keyHandler = - remember(preferences) { - PlaybackKeyHandler( - player = player, - controlsEnabled = true, - skipWithLeftRight = false, - seekForward = 30.seconds, - seekBack = 10.seconds, -// seekForward = preferences.playbackPreferences.skipForwardMs.milliseconds, -// seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, - controllerViewState = controllerViewState, - updateSkipIndicator = {}, - clearSkipIndicator = {}, - skipBackOnResume = null, -// skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, - onInteraction = viewModel::reportInteraction, - oneClickPause = preferences.playbackPreferences.oneClickPause, - onStop = { - viewModel.stop() - }, - onPlaybackDialogTypeClick = { }, - getDurationMs = { player.duration }, - dpadSeekMode = preferences.playbackPreferences.dpadSeekMode, - onSeekToNext = { - if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext() - }, - onPSeekToPrevious = { - if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious() - }, - ) - } - - var showViewOptionsDialog by remember { mutableStateOf(false) } - var showContextMenu by remember { mutableStateOf<ContextMenu.ForQueue?>(null) } - - 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(lyricsFocused) { - if (lyricsFocused != null) { - controllerViewState.hideControls() - delay(5.seconds) - if (!controllerViewState.controlsVisible) { - focusRequester.tryRequestFocus() - } - lyricsFocused = null - } - } - BackHandler(lyricsFocused != null) { - focusRequester.tryRequestFocus() - } - - var showRationaleDialog by remember { mutableStateOf(false) } - val launcher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted -> - viewModel.startVisualizer(isGranted, true) - } - - Box(modifier) { - Backdrop( - backdrop = state.backdropResult, - drawerIsOpen = false, - backdropStyle = BackdropStyle.BACKDROP_DYNAMIC_COLOR, - modifier = Modifier.fillMaxSize(), - enableTopScrim = false, - useExistingImageAsPlaceholder = true, - crossfadeDuration = 1.5.seconds, - ) - Box( - modifier = - Modifier - .fillMaxSize() - .onPreviewKeyEvent { key -> - if (!controllerViewState.controlsVisible && - key.type == KeyEventType.KeyUp && - isUp(key) && - hasLyrics - ) { - lyricsFocusRequester.tryRequestFocus() - true - } else { - keyHandler.onKeyEvent(key) - } - }.focusRequester(focusRequester) - .focusable(), - ) - Row( - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxSize(), - ) { - Box( - modifier = Modifier.wrapContentWidth(), - ) { - val enter = - remember { - expandHorizontally(expandFrom = Alignment.Start) + fadeIn() - } - val exit = - remember { - shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() - } - //noinspection UnnecessaryFullyQualifiedName - androidx.compose.animation.AnimatedVisibility( - visible = musicPrefs.showAlbumArt, - enter = enter, - exit = exit, - modifier = - Modifier - .padding(32.dp), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(.5f), - ) { - AsyncImage( - contentDescription = null, - model = current?.imageUrl, - modifier = - Modifier - .size(240.dp) - .clip(RoundedCornerShape(16.dp)), - ) - current?.title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - current?.albumTitle?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - ) - } - current?.artistNames?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - //noinspection UnnecessaryFullyQualifiedName - androidx.compose.animation.AnimatedVisibility( - visible = musicPrefs.showVisualizer, - enter = enter, - exit = exit, - modifier = - Modifier - .padding(horizontal = 32.dp) - .align(Alignment.CenterStart), - ) { - val visualizerWidth by animateFloatAsState(if (musicPrefs.showLyrics && state.hasLyrics) .5f else 1f) - BarVisualizer( - data = viz, - modifier = - Modifier - .fillMaxHeight(.75f) - .fillMaxWidth(visualizerWidth) - .align(Alignment.CenterStart), - ) - } - } - - AnimatedVisibility( - visible = musicPrefs.showLyrics && state.hasLyrics, - enter = expandHorizontally(expandFrom = Alignment.End), - exit = shrinkHorizontally(shrinkTowards = Alignment.End), - modifier = - Modifier - .focusRequester(lyricsFocusRequester), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .padding(horizontal = 32.dp, vertical = 100.dp) - .fillMaxHeight(), - ) { - LyricsContent( - lyrics = state.lyrics, - currentLyricPosition = state.currentLyricIndex, - lyricsHaveFocus = lyricsFocused != null, - onFocusLyrics = { - lyricsFocused = Date() - }, - onClick = { - it.start - ?.ticks - ?.inWholeMilliseconds - ?.let { player.seekTo(it) } - }, - modifier = - Modifier - .fillMaxSize(), -// .width(360.dp), - ) - } - } - } - val showContextForItem = - remember { - { fromLongClick: Boolean, index: Int, song: AudioItem -> - showContextMenu = - ContextMenu.ForQueue( - fromLongClick = fromLongClick, - item = song, - index = index, - actions = - QueueContextActions( - onNavigate = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, _ -> viewModel.play(index) }, - onClickPlayNext = { index, _ -> viewModel.playNext(index) }, - onClickRemoveFromQueue = { index, _ -> - viewModel.removeFromQueue( - index, - ) - }, - ), - ) - } - } - - BackHandler(controllerViewState.controlsVisible) { - controllerViewState.hideControls() - } - AnimatedVisibility( - visible = controllerViewState.controlsVisible, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = - Modifier - .align(Alignment.BottomCenter), - ) { - NowPlayingOverlay( - state = state, - player = player, - current = current, - controllerViewState = controllerViewState, - onMoveQueue = { index, direction -> viewModel.moveQueue(index, direction) }, - onClickMore = { showViewOptionsDialog = true }, - onClickSong = { index, _ -> viewModel.play(index) }, - onClickMoreItem = { index, song -> showContextForItem.invoke(false, index, song) }, - onLongClickSong = { index, song -> showContextForItem.invoke(true, index, song) }, - onClickStop = { viewModel.stop() }, - lyricsFocusRequester = lyricsFocusRequester, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .drawBehind { - drawRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black), - startY = 0f, - endY = size.height, - ), - ) - }, - ) - } - if (state.musicServiceState.loadingState is LoadingState.Loading) { - LoadingPage(focusEnabled = false) - } - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - if (showViewOptionsDialog) { - MusicViewOptionsDialog( - appPreferences = preferences, - onDismissRequest = { showViewOptionsDialog = false }, - onViewOptionsChange = { viewModel.updatePreferences(it) }, - onEnableVisualizer = { - val showRationale = - context - .findActivity() - ?.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) == true - when { - !state.visualizerPermissions && showRationale -> { - showRationaleDialog = true - } - - !state.visualizerPermissions -> { - launcher.launch(Manifest.permission.RECORD_AUDIO) - } - - else -> { - viewModel.startVisualizer(true, true) - } - } - }, - ) - } - if (showRationaleDialog) { - RecordAudioRationaleDialog( - onDismissRequest = { showRationaleDialog = false }, - onClick = { - showRationaleDialog = false - launcher.launch(Manifest.permission.RECORD_AUDIO) - }, - ) - } -} - -@Composable -fun NowPlayingBottomDialog( - showDebugInfo: Boolean, - lyricsActive: Boolean, - songHasLyrics: Boolean, - onDismissRequest: () -> Unit, - onClickShowDebug: () -> Unit, - onClickLyrics: () -> Unit, -) { - val choices = - mapOf( - BottomDialogItem( - data = 0, - headline = stringResource(if (showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), - supporting = null, - ) to onClickShowDebug, - BottomDialogItem( - data = 0, - headline = stringResource(if (lyricsActive) R.string.hide_lyrics else R.string.show_lyrics), - supporting = if (songHasLyrics) stringResource(R.string.song_has_lyrics) else null, - ) to onClickLyrics, - ) - - BottomDialog( - choices = choices.keys.toList(), - onDismissRequest = { - onDismissRequest.invoke() - }, - onSelectChoice = { _, choice -> - choices[choice]?.invoke() - onDismissRequest.invoke() - }, - gravity = Gravity.START, - ) -} - -@Composable -private fun RecordAudioRationaleDialog( - onDismissRequest: () -> Unit, - onClick: () -> Unit, -) { - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), - ) { - Text( - text = stringResource(R.string.visualizer_rationale), - color = MaterialTheme.colorScheme.onSurface, - ) - Button( - onClick = onClick, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.continue_string), - ) - } - } - } -} 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 deleted file mode 100644 index cfaa1cfc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingState.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 - -@Stable -data class NowPlayingState( - val musicServiceState: MusicServiceState, - val currentMediaItem: AudioItem? = null, - val lyrics: LyricDto? = null, - val currentLyricIndex: Int? = null, - val visualizerPermissions: Boolean = false, - val backdropResult: BackdropResult = BackdropResult.NONE, -) { - val hasLyrics: Boolean get() = lyrics != null && lyrics.lyrics.isNotEmpty() -} 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 deleted file mode 100644 index af14ff70..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingViewModel.kt +++ /dev/null @@ -1,393 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.media.audiofx.Visualizer -import androidx.core.content.ContextCompat -import androidx.datastore.core.DataStore -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.updateMusicPreferences -import com.github.damontecres.wholphin.services.BackdropResult -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.ExtractedColors -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.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.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.lyricsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ImageType -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.minutes - -@UnstableApi -@HiltViewModel(assistedFactory = NowPlayingViewModel.Factory::class) -class NowPlayingViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val musicService: MusicService, - private val backdropService: BackdropService, - private val imageUrlService: ImageUrlService, - private val preferencesDataStore: DataStore<AppPreferences>, - val navigationManager: NavigationManager, - val userPreferencesService: UserPreferencesService, - ) : ViewModel(), - Visualizer.OnDataCaptureListener, - Player.Listener { - @AssistedFactory - interface Factory { - fun create(): NowPlayingViewModel - } - - private val visualizerMutex = Mutex() - private var visualizer: Visualizer? = null - - val controllerViewState = - ControllerViewState( - AppPreference.ControllerTimeout.defaultValue, - true, - ) - - val state = MutableStateFlow(NowPlayingState(musicService.state.value)) - val player get() = musicService.player - - val viz = MutableStateFlow<IntArray>(IntArray(0)) - - private val lyricCache = - InMemoryKache<UUID, LyricDto>(20) { - creationScope = CoroutineScope(WholphinDispatchers.IO) - } - - init { - state.update { - it.copy(currentMediaItem = player.currentMediaItem?.localConfiguration?.tag as? AudioItem) - } - player.addListener(this) - addCloseable { - player.removeListener(this) - visualizer?.release() - } - val visualizerPermissions = - ContextCompat.checkSelfPermission( - context, - Manifest.permission.RECORD_AUDIO, - ) == PackageManager.PERMISSION_GRANTED - startVisualizer(visualizerPermissions, false) - viewModelScope.launchDefault { - musicService.state.collectLatest { musicServiceState -> - if (musicServiceState.status != NowPlayingStatus.IDLE) { - visualizer?.enabled = musicServiceState.status == NowPlayingStatus.PLAYING - } - - state.update { it.copy(musicServiceState = musicServiceState) } - } - } - viewModelScope.launchDefault { - viewModelScope - .launchDefault { - controllerViewState.observe() - }.join() - controllerViewState.pulseControls() - } - viewModelScope.launchDefault { backdropService.clearBackdrop() } - listenForBackdrop() - listenForLyrics() - } - - fun reportInteraction() { - controllerViewState.pulseControls() - } - - private fun listenForLyrics() { - viewModelScope.launchDefault { - 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 = - lyrics.lyrics - .indexOfLast { - it.start?.ticks?.let { lyricPosition >= it } == true - }.takeIf { it >= 0 } - state.update { - it.copy( - lyrics = lyrics, - currentLyricIndex = lyricIndex, - ) - } - delay(150.milliseconds) - } - } - } - } - } - - override fun onMediaItemTransition( - mediaItem: MediaItem?, - reason: Int, - ) { - val audio = mediaItem?.localConfiguration?.tag as? AudioItem - Timber.v("onMediaItemTransition to %s", audio?.id) - state.update { - it.copy( - currentMediaItem = audio, - lyrics = null, - currentLyricIndex = null, - ) - } - } - - private suspend fun fetchAndUpdateLyrics(audio: AudioItem?): LyricDto? = - audio?.let { audio -> - if (audio.hasLyrics) { - lyricCache.getOrPut(audio.id) { - // TODO remote lyrics? - try { - api.lyricsApi.getLyrics(audio.id).content - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error fetching lyrics for %s", audio.id) - null - } - } - } else { - null - } - } - - 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 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, - ) ?: ExtractedColors.DEFAULT - val backdropResult = - BackdropResult( - itemId = itemId.toString(), - imageUrl = imageUrl, - primaryColor = primaryColor, - secondaryColor = secondaryColor, - tertiaryColor = tertiaryColor, - ) - state.update { it.copy(backdropResult = backdropResult) } - } - - private fun clearBackdrop() { - state.update { it.copy(backdropResult = BackdropResult.NONE) } - } - - fun moveQueue( - index: Int, - direction: MoveDirection, - ) = viewModelScope.launchDefault { musicService.moveQueue(index, direction) } - - fun play(index: Int) = viewModelScope.launchDefault { musicService.playIndex(index) } - - fun playNext(index: Int) = viewModelScope.launchDefault { musicService.moveQueue(index, 1) } - - fun removeFromQueue(index: Int) = viewModelScope.launchDefault { musicService.removeFromQueue(index) } - - fun stop() { - viewModelScope.launchDefault { - musicService.stop() - navigationManager.goBack() - } - } - - override fun onFftDataCapture( - visualizer: Visualizer, - fft: ByteArray, - samplingRate: Int, - ) { - } - - override fun onWaveFormDataCapture( - visualizer: Visualizer, - waveform: ByteArray, - samplingRate: Int, - ) { - val resolution = 96 - val captureSize = - Visualizer.getCaptureSizeRange()[1] - val groupSize = (captureSize / resolution.toFloat()).toInt() - val processed = - waveform - .toList() - .chunked(groupSize) - .map { it.average().toInt() + 128 } - .toIntArray() - viz.update { processed } - } - - fun updatePreferences(prefs: AppPreferences) { - viewModelScope.launchDefault { - preferencesDataStore.updateData { - prefs - } - } - } - - private fun initVisualizer() { - viewModelScope.launchDefault { - visualizerMutex.withLock { - val prefs = preferencesDataStore.data.first() - if (visualizer == null && - state.value.visualizerPermissions && - prefs.musicPreferences.showVisualizer - ) { - Timber.v("Creating visualizer") - visualizer = - Visualizer(onMain { player.audioSessionId }).apply { - captureSize = Visualizer.getCaptureSizeRange()[1] - setDataCaptureListener( - this@NowPlayingViewModel, - Visualizer.getMaxCaptureRate() / 3, - true, - false, - ) - enabled = true - } - } - } - } - } - - fun startVisualizer( - permissionGranted: Boolean, - updatePreferences: Boolean, - ) { - Timber.v("startVisualizer: permissionGranted=%s", permissionGranted) - state.update { - it.copy( - visualizerPermissions = permissionGranted, - ) - } - viewModelScope.launchDefault { - if (updatePreferences || !permissionGranted) { - preferencesDataStore.updateData { - it.updateMusicPreferences { showVisualizer = permissionGranted } - } - } - initVisualizer() - } - } - } 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 deleted file mode 100644 index acc802a8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongDetailsPage.kt +++ /dev/null @@ -1,270 +0,0 @@ -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 deleted file mode 100644 index e4664ac9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/SongListDisplay.kt +++ /dev/null @@ -1,245 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -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.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.ListItemDefaults -import androidx.tv.material3.LocalContentColor -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.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 -import org.jellyfin.sdk.model.extensions.ticks -import kotlin.time.Duration -import kotlin.time.Duration.Companion.minutes -import kotlin.time.Duration.Companion.seconds - -@Composable -fun SongListItem( - song: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - onClickMore: () -> Unit, - modifier: Modifier = Modifier, - showArtist: Boolean = false, - isPlaying: Boolean = false, - isQueued: Boolean = false, -) = SongListItem( - title = song?.title, - artist = - if (showArtist) { - remember(song) { song?.data?.artists?.joinToString(", ") } - } else { - null - }, - indexNumber = song?.data?.indexNumber, - runtime = - remember(song) { - song - ?.data - ?.runTimeTicks - ?.ticks - ?.roundSeconds - }, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - showArtist = showArtist, - isPlaying = isPlaying, - isQueued = isQueued, - showMoreButton = true, - onClickMore = onClickMore, -) - -@Composable -fun SongListItem( - title: String?, - artist: String?, - indexNumber: Int?, - runtime: Duration?, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, - showArtist: Boolean = false, - isPlaying: Boolean = false, - isQueued: Boolean = false, - showMoreButton: Boolean = false, - onClickMore: () -> Unit = {}, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - val focused by interactionSource.collectIsFocusedAsState() - val leadingContent: @Composable (BoxScope.() -> Unit) = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = indexNumber?.toString() ?: "", - ) - MusicQueueMarker( - isPlaying = isPlaying, - isQueued = isQueued, - ) - } - } - val headlineContent = @Composable { - Text( - text = title ?: "", - maxLines = 1, - modifier = Modifier.enableMarquee(focused), - ) - } - val trailingContent = @Composable { - Text( - text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "", - ) - } - - DenseListItem( - selected = isPlaying, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - leadingContent = leadingContent, - headlineContent = headlineContent, - supportingContent = - if (showArtist && artist != null) { - { - Text( - text = artist, - ) - } - } else { - null - }, - trailingContent = trailingContent, - scale = ListItemDefaults.scale(1f, 1f, .95f), - modifier = Modifier.weight(1f), - ) - if (showMoreButton) { - Button( - onClick = onClickMore, - enabled = true, - modifier = Modifier.size(32.dp), - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.more), - ) - } - } - } -} - -val BaseItem.artistsString: String? get() = data.artists?.letNotEmpty { it.joinToString(", ") } - -/** - * Add an indicator for if the item is currently playing or queued - */ -@Composable -fun MusicQueueMarker( - isPlaying: Boolean, - isQueued: Boolean, - modifier: Modifier = Modifier, -) { - if (isPlaying) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = modifier, - ) - } else if (isQueued) { - Box( - modifier = - modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(LocalContentColor.current) - .size(8.dp), - ) - } -} - -@PreviewTvSpec -@Composable -fun SongListItemPreview() { - WholphinTheme { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SongListItem( - title = "Song title", - artist = "Artists", - indexNumber = 1, - runtime = 2.minutes + 30.seconds, - onClick = {}, - onLongClick = { }, - modifier = Modifier, - showArtist = false, - ) - SongListItem( - title = "Song title", - artist = "Artists", - indexNumber = 1, - runtime = 2.minutes + 30.seconds, - onClick = {}, - onLongClick = { }, - modifier = Modifier, - showArtist = false, - isQueued = true, - showMoreButton = true, - ) - SongListItem( - title = "Song title", - artist = "Artists", - indexNumber = 2, - runtime = 2.minutes + 30.seconds, - onClick = {}, - onLongClick = { }, - modifier = Modifier, - showArtist = true, - isPlaying = true, - ) - SongListItem( - title = "Song title", - artist = null, - indexNumber = 2, - runtime = 2.minutes + 30.seconds, - onClick = {}, - onLongClick = { }, - modifier = Modifier, - showArtist = true, - isPlaying = true, - ) - } - } -} 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 deleted file mode 100644 index 26346e7c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/Utils.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.music - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import java.util.UUID - -suspend fun ViewModel.getPagerForAlbum( - api: ApiClient, - albumId: UUID, -): ApiRequestPager<GetItemsRequest> { - val request = - GetItemsRequest( - parentId = albumId, - includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DetailItemFields, - sortBy = - listOf( - ItemSortBy.PARENT_INDEX_NUMBER, - ItemSortBy.INDEX_NUMBER, - ItemSortBy.SORT_NAME, - ), - ) - return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() -} - -suspend fun ViewModel.getPagerForArtist( - api: ApiClient, - artistId: UUID, -): ApiRequestPager<GetItemsRequest> { - val request = - GetItemsRequest( - artistIds = listOf(artistId), - recursive = true, - includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DetailItemFields, - // TODO better sort - sortBy = - listOf( - ItemSortBy.PARENT_INDEX_NUMBER, - ItemSortBy.INDEX_NUMBER, - ItemSortBy.SORT_NAME, - ), - ) - return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() -} - -suspend fun ViewModel.getPagerForPlaylist( - api: ApiClient, - playlistId: UUID, -): ApiRequestPager<GetItemsRequest> { - val request = - GetItemsRequest( - parentId = playlistId, - recursive = true, - includeItemTypes = listOf(BaseItemKind.AUDIO), - fields = DetailItemFields, - sortBy = - listOf( - ItemSortBy.DEFAULT, - ), - ) - return ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() -} 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 600dee28..e21e5dc6 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 @@ -2,19 +2,15 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth 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.FocusState 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,11 +23,7 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, - canDelete: Boolean, - onConfirmDelete: () -> Unit, - onChooseVersion: (MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, - buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { val dto = ep.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -42,7 +34,6 @@ fun FocusedEpisodeFooter( modifier = modifier, ) { ExpandablePlayButtons( - title = ep.title ?: "", resumePosition = resumePosition, watched = dto.userData?.played ?: false, favorite = dto.userData?.isFavorite ?: false, @@ -50,20 +41,8 @@ fun FocusedEpisodeFooter( moreOnClick = moreOnClick, watchOnClick = watchOnClick, favoriteOnClick = favoriteOnClick, - buttonOnFocusChanged = buttonOnFocusChanged, - trailers = null, - trailerOnClick = {}, - canDelete = canDelete, - onConfirmDelete = onConfirmDelete, - chooseVersionParams = - remember(chosenStreams, ep, onChooseVersion) { - ChooseVersionParams( - chosenStreams = chosenStreams, - mediaSources = ep.data.mediaSources.orEmpty(), - onChooseVersion = onChooseVersion, - ) - }, - modifier = Modifier.fillMaxWidth(), + buttonOnFocusChanged = {}, + modifier = Modifier, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index be7795f4..9eb9330a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -2,21 +2,25 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text 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.EpisodeName -import com.github.damontecres.wholphin.ui.components.HeaderUtils +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.timeRemaining +import org.jellyfin.sdk.model.extensions.ticks @Composable fun FocusedEpisodeHeader( @@ -24,7 +28,6 @@ fun FocusedEpisodeHeader( ep: BaseItem?, chosenStreams: ChosenStreams?, overviewOnClick: () -> Unit, - overviewOnFocus: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -33,28 +36,45 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) - - ep?.let { - QuickDetails( - it, - it.timeRemainingOrRuntime, - Modifier.padding(start = HeaderUtils.startPadding), - ) - } + Text( + text = dto?.episodeTitle ?: dto?.name ?: "", + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + val details = + remember(dto) { + buildList { + dto?.seasonEpisode?.let(::add) + dto?.premiereDate?.let { add(formatDateTime(it)) } + val duration = dto?.runTimeTicks?.ticks + duration + ?.roundMinutes + ?.toString() + ?.let(::add) + dto?.timeRemaining?.roundMinutes?.let { add("$it left") } + dto?.officialRating?.let(::add) + } + } + DotSeparatedRow( + texts = details, + rating = dto?.communityRating, + textStyle = MaterialTheme.typography.titleSmall, + ) if (dto != null) { VideoStreamDetails( - chosenStreams = chosenStreams, - numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier.padding(start = HeaderUtils.startPadding), + preferences = preferences, + dto = dto, + itemPlayback = chosenStreams?.itemPlayback, + modifier = Modifier, ) } OverviewText( overview = dto?.overview ?: "", maxLines = 3, onClick = overviewOnClick, - modifier = Modifier.onFocusChanged(overviewOnFocus), ) } } 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 0308468e..d1a6143a 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 @@ -1,10 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.series -import android.content.res.Resources -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.focusGroup +import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,11 +13,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -29,66 +25,61 @@ 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color 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 import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text 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.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.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ConfirmDialog -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.DeleteButton +import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage 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.DotSeparatedRow 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.GenreText -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.components.OverviewText -import com.github.damontecres.wholphin.ui.components.PersonContextActions -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.components.TitleOrLogo -import com.github.damontecres.wholphin.ui.components.TrailerButton 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.discover.DiscoverRow -import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.util.ResStringProvider -import com.github.damontecres.wholphin.util.DataLoadingState -import com.github.damontecres.wholphin.util.DiscoverRequestType +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import kotlin.time.Duration @@ -97,194 +88,150 @@ fun SeriesDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = - hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>( - creationCallback = { - it.create(destination.itemId, null, SeriesPageType.DETAILS) - }, - ), + viewModel: SeriesViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.init(preferences, destination.itemId, null, true) + } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val state by viewModel.state.collectAsState() - val playlistState by playlistViewModel.playlistState.collectAsState() + val item by viewModel.item.observeAsState() + val seasons by viewModel.seasons.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) + val extras by viewModel.extras.observeAsState(listOf()) + val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } - var showWatchConfirmation by remember { mutableStateOf(false) } + var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - if (itemId == destination.itemId) { - // Confirm if marking whole series - showWatchConfirmation = true - } else { - viewModel.setWatched(itemId, watched, null) + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + LoadingState.Success -> { + item?.let { item -> + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() } - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite, null) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog = Optional.present(itemId) - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = viewModel::deleteItem, - onChooseVersion = { item, source -> - viewModel.savePlayVersion( - item, - source.id!!.toUUID(), - ) - }, - onChooseTracks = { result -> - viewModel.saveTrackSelection( - result.item, - result.itemPlayback, - result.trackIndex, - result.streamType, - ) - }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onClearChosenStreams = {}, - ) - } + } - LifecycleResumeEffect(destination.itemId) { - viewModel.refresh() - - onPauseOrDispose { - viewModel.release() - } - } - - when (val st = state.series) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<BaseItem> -> { - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose {} - } - val series = st.data - val played = series.data.userData?.played ?: false - SeriesDetailsContent( - preferences = preferences, - series = series, - seasons = state.seasons, - trailers = state.trailers, - extras = state.extras, - people = state.people, - similar = state.similar, - played = played, - favorite = series.data.userData?.isFavorite ?: false, - canDelete = state.canDeleteSeries, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - onLongClickItem = { index, season -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = season, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(season, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - overviewOnClick = { - overviewDialog = ItemDetailsDialogInfo(series) - }, - playOnClick = { shuffle -> - if (shuffle) { + val played = item.data.userData?.played ?: false + SeriesDetailsContent( + preferences = preferences, + series = item, + seasons = seasons, + trailers = trailers, + extras = extras, + people = people, + similar = similar, + played = played, + favorite = item.data.userData?.isFavorite ?: false, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { viewModel.navigateTo( - Destination.PlaybackList( - itemId = series.id, - shuffle = true, + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, ), ) - } else { - viewModel.playNextUp() - } - }, - watchOnClick = { showWatchConfirmation = true }, - favoriteOnClick = { - val favorite = series.data.userData?.isFavorite ?: false - viewModel.setFavorite(series.id, !favorite, null) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { _, extra -> - viewModel.navigateTo(extra.destination) - }, - discoverSeries = state.discoverSeries, - onClickDiscoverSeries = { - state.discoverSeries?.let { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - } - }, - discovered = state.discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - onShowContextMenu = { - showContextMenu = it - }, - actions = contextActions, - ) - if (showWatchConfirmation) { - ConfirmDialog( - title = series.name ?: "", - body = - stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), - onCancel = { - showWatchConfirmation = false }, - onConfirm = { - viewModel.setWatchedSeries(!played) - showWatchConfirmation = false + onLongClickItem = { index, season -> + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + ) }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: context.getString(R.string.unknown), + overview = item.data.overview, + files = listOf(), + ) + }, + playOnClick = { shuffle -> + if (shuffle) { + viewModel.navigateTo( + Destination.PlaybackList( + itemId = item.id, + shuffle = true, + ), + ) + } else { + viewModel.playNextUp() + } + }, + watchOnClick = { showWatchConfirmation = true }, + favoriteOnClick = { + val favorite = item.data.userData?.isFavorite ?: false + viewModel.setFavorite(item.id, !favorite, null) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { _, extra -> + viewModel.navigateTo(extra.destination) + }, + moreActions = + MoreDialogActions( + navigateTo = { viewModel.navigateTo(it) }, + onClickWatch = { itemId, played -> + viewModel.setWatched(itemId, played, null) + }, + onClickFavorite = { itemId, played -> + viewModel.setFavorite(itemId, played, null) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + ), ) + if (showWatchConfirmation) { + ConfirmDialog( + title = item.name ?: "", + body = + stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), + onCancel = { + showWatchConfirmation = false + }, + onConfirm = { + viewModel.setWatchedSeries(!played) + showWatchConfirmation = false + }, + ) + } } } } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = viewModel.streamChoiceService::chooseSource, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } overviewDialog?.let { info -> ItemDetailsDialog( info = info, @@ -292,6 +239,15 @@ fun SeriesDetails( onDismissRequest = { overviewDialog = null }, ) } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -306,7 +262,6 @@ fun SeriesDetails( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog.makeAbsent() }, - onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -318,21 +273,18 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val EXTRAS_ROW = TRAILER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 -private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun SeriesDetailsContent( preferences: UserPreferences, series: BaseItem, - seasons: List<BaseItem?>, + seasons: List<BaseItem>, similar: List<BaseItem>, trailers: List<Trailer>, extras: List<ExtrasItem>, people: List<Person>, - discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, - canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -342,11 +294,7 @@ fun SeriesDetailsContent( favoriteOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, - onShowContextMenu: (ContextMenu) -> Unit, - actions: ContextMenuActions, - onClickDiscover: (Int, DiscoverItem) -> Unit, - discoverSeries: DiscoverItem?, - onClickDiscoverSeries: () -> Unit, + moreActions: MoreDialogActions, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -354,16 +302,21 @@ fun SeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } - val playFocusRequester = remember { FocusRequester() } - RequestOrRestoreFocus(focusRequesters.getOrNull(position)) + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + var moreDialog by remember { mutableStateOf<DialogParams?>(null) } Box( modifier = modifier, ) { + DetailsBackdropImage(series.backdropImageUrl) + Column( modifier = Modifier + .padding(16.dp) .fillMaxSize(), ) { LazyColumn( @@ -374,24 +327,20 @@ fun SeriesDetailsContent( item { SeriesDetailsHeader( series = series, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, - bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier - .fillMaxWidth() + .fillMaxWidth(.7f) .bringIntoViewRequester(bringIntoViewRequester) - .padding(top = HeaderUtils.topPadding, bottom = 16.dp), + .padding(bottom = 8.dp), ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = HeaderUtils.startPadding) + .padding(start = 16.dp) .focusRequester(focusRequesters[HEADER_ROW]) - .focusRestorer(playFocusRequester) - .focusGroup() - .padding(bottom = 16.dp), + .padding(bottom = 80.dp), ) { ExpandablePlayButton( title = R.string.play, @@ -402,15 +351,13 @@ fun SeriesDetailsContent( playOnClick.invoke(false) }, modifier = - Modifier - .focusRequester(playFocusRequester) - .onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() } - }, + } + }, ) ExpandableFaButton( title = R.string.shuffle, @@ -455,60 +402,11 @@ fun SeriesDetailsContent( } }, ) - TrailerButton( - trailers = trailers, - trailerOnClick = trailerOnClick, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - if (canDelete) { - DeleteButton( - title = series.title ?: "", - onConfirmDelete = { - position = HEADER_ROW - actions.onDeleteItem.invoke(series) - }, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } - AnimatedVisibility( - visible = discoverSeries != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - ExpandableFaButton( - title = R.string.discover, - iconStringRes = R.string.fa_magnifying_glass_plus, - onClick = onClickDiscoverSeries, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } } } item { ItemRow( - title = stringResource(R.string.tv_seasons) + " (${seasons.size})", + title = stringResource(R.string.tv_seasons), items = seasons, onClickItem = { index, item -> position = SEASONS_ROW @@ -545,17 +443,18 @@ fun SeriesDetailsContent( }, onLongClick = { index, person -> position = PEOPLE_ROW - onShowContextMenu.invoke( - ContextMenu.ForPerson( - fromLongClick = true, + val items = + buildMoreDialogItemsForPerson( + context = context, person = person, - actions = - PersonContextActions( - navigateTo = actions.navigateTo, - onClickFavorite = actions.onClickFavorite, - ), - ), - ) + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) }, modifier = Modifier @@ -564,6 +463,21 @@ fun SeriesDetailsContent( ) } } + if (trailers.isNotEmpty()) { + item { + TrailerRow( + trailers = trailers, + onClickTrailer = { + position = TRAILER_ROW + trailerOnClick.invoke(it) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[TRAILER_ROW]), + ) + } + } if (extras.isNotEmpty()) { item { ExtrasRow( @@ -591,19 +505,22 @@ fun SeriesDetailsContent( }, onLongClickItem = { index, item -> position = SIMILAR_ROW - onShowContextMenu.invoke( - ContextMenu.ForBaseItem( - fromLongClick = true, + val items = + buildMoreDialogItemsForHome( + context = context, item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = false, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = actions, - ), - ) + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = items, + ) }, cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( @@ -623,151 +540,119 @@ fun SeriesDetailsContent( ) } } - if (discovered.isNotEmpty()) { - item { - DiscoverRow( - row = - DiscoverRowData( - ResStringProvider(R.string.discover), - DataLoadingState.Success(discovered), - type = DiscoverRequestType.UNKNOWN, - ), - onClickItem = { index: Int, item: DiscoverItem -> - position = DISCOVER_ROW - onClickDiscover.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - onCardFocus = {}, - focusRequester = focusRequesters[DISCOVER_ROW], - ) - } - } } } } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } @Composable fun SeriesDetailsHeader( series: BaseItem, - showLogo: Boolean, overviewOnClick: () -> Unit, - bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() val dto = series.data + val details = + buildList { + dto.productionYear?.let { add(it.toString()) } + dto.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { add(it.toString()) } + dto.officialRating?.let(::add) + } Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - TitleOrLogo( - item = series, - showLogo = showLogo, - modifier = - Modifier - .fillMaxWidth(.75f) - .padding(start = HeaderUtils.startPadding), + Text( + text = series.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.fillMaxWidth(), ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(.60f), - ) { - QuickDetails( - series, - null, - Modifier.padding(start = HeaderUtils.startPadding), + DotSeparatedRow( + texts = details, + rating = dto.communityRating, + textStyle = MaterialTheme.typography.titleMedium, + ) + dto.genres?.letNotEmpty { + Text( + text = it.joinToString(", "), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + dto.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, ) - dto.studios?.let { - val studios = remember { series.studioNames } - GenreText( - studios, - textStyle = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) - } - dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) - } - dto.overview?.let { overview -> - OverviewText( - overview = overview, - maxLines = 3, - onClick = overviewOnClick, - textBoxHeight = Dp.Unspecified, - modifier = - Modifier.onFocusChanged { - if (it.isFocused) { - scope.launch(ExceptionHandler()) { - bringIntoViewRequester.bringIntoView() - } - } - }, - ) - } } } } fun buildDialogForSeason( - resources: Resources, + context: Context, s: BaseItem, - canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, - onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { add( - DialogItem(resources.getString(R.string.go_to), Icons.Default.PlayArrow) { + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { onClickItem.invoke(s) }, ) if (s.data.userData?.played == true) { add( - DialogItem(resources.getString(R.string.mark_unwatched), R.string.fa_eye) { + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { markPlayed.invoke(false) }, ) } else { add( - DialogItem(resources.getString(R.string.mark_watched), R.string.fa_eye_slash) { + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { markPlayed.invoke(true) }, ) } add( DialogItem( - resources.getString(R.string.play), + context.getString(R.string.play), Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), ) { onClickPlay.invoke(false) }, ) add( DialogItem( - resources.getString(R.string.shuffle), + context.getString(R.string.shuffle), R.string.fa_shuffle, ) { onClickPlay.invoke(true) }, ) - if (canDelete) { - add( - DialogItem( - resources.getString(R.string.delete), - Icons.Default.Delete, - iconColor = Color.Red.copy(alpha = .8f), - ) { - onClickDelete.invoke(s) - }, - ) - } } return DialogParams( - title = s.name ?: resources.getString(R.string.tv_season), + title = s.name ?: context.getString(R.string.tv_season), fromLongClick = true, items = items, ) 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 ccc8f493..ad5befee 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 @@ -4,42 +4,52 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +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.platform.LocalContext 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.lifecycle.compose.LifecycleStartEffect import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus -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.OneTimeLaunchedEffect +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.LoadingPage +import com.github.damontecres.wholphin.ui.components.chooseStream +import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.util.DataLoadingState -import kotlinx.coroutines.flow.update +import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.PersonKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID +import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -67,272 +77,301 @@ data class SeriesOverviewPosition( fun SeriesOverview( preferences: UserPreferences, destination: Destination.SeriesOverview, - initialSeasonEpisode: SeasonEpisodeIds?, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = - hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>( - creationCallback = { - it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW) - }, - ), + viewModel: SeriesViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + initialSeasonEpisode: SeasonEpisodeIds? = null, ) { + val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } - val castCrewRowFocusRequester = remember { FocusRequester() } - val guestStarRowFocusRequester = remember { FocusRequester() } - val extrasRowFocusRequester = remember { FocusRequester() } - val state by viewModel.state.collectAsState() - val episodeList = - remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes } + var initialLoadDone by rememberSaveable { mutableStateOf(false) } + OneTimeLaunchedEffect { + Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") + viewModel.init( + preferences, + destination.itemId, + initialSeasonEpisode, + false, + ) + initialLoadDone = true + } - val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) - val currentPosition by rememberUpdatedState(position) - LaunchedEffect(Unit) { - if (state.seasons.isNotEmpty()) { - state.seasons.getOrNull(position.seasonTabIndex)?.let { - viewModel.loadEpisodes(it.id, it.indexNumber) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + val series by viewModel.item.observeAsState(null) + val seasons by viewModel.seasons.observeAsState(listOf()) + val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) + val episodeList = (episodes as? EpisodeList.Success)?.episodes + + var position by rememberSaveable( + destination, + loading, + stateSaver = + Saver( + save = { listOf(it.seasonTabIndex, it.episodeRowIndex) }, + restore = { SeriesOverviewPosition(it[0], it[1]) }, + ), + ) { + mutableStateOf( + SeriesOverviewPosition( + seasons.indexOfFirstOrNull { + equalsNotNull(it.id, initialSeasonEpisode?.seasonId) || + equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber) + } ?: 0, + (episodes as? EpisodeList.Success)?.initialIndex ?: 0, + ), + ) + } + if (initialLoadDone) { + LaunchedEffect(Unit) { + seasons.getOrNull(position.seasonTabIndex)?.let { + viewModel.loadEpisodes(it.id) } } } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } - val playlistState by playlistViewModel.playlistState.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - var rowFocused by rememberInt() - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(itemId, watched, currentPosition.episodeRowIndex) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite, currentPosition.episodeRowIndex) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog = itemId - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = viewModel::deleteItem, - onChooseVersion = { item, source -> - viewModel.savePlayVersion( - item, - source.id!!.toUUID(), - ) - }, - onChooseTracks = { result -> - viewModel.saveTrackSelection( - result.item, - result.itemPlayback, - result.trackIndex, - result.streamType, - ) - }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onClearChosenStreams = { - val focusedEpisode = - (state.episodes as? EpisodeList.Success) - ?.episodes - ?.getOrNull(currentPosition.episodeRowIndex) - if (focusedEpisode != null) { - viewModel.clearChosenStreams(focusedEpisode, it) + LaunchedEffect(episodes) { + episodes?.let { episodes -> + if (episodes is EpisodeList.Success) { + if (episodes.episodes.isNotEmpty()) { + // TODO focus on first episode when changing seasons? +// firstItemFocusRequester.requestFocus() + episodes.episodes.getOrNull(position.episodeRowIndex)?.let { + viewModel.refreshEpisode(it.id, position.episodeRowIndex) } - }, - ) - } - - LaunchedEffect(position, state.episodes) { - val focusedEpisode = - (state.episodes as? EpisodeList.Success) - ?.episodes - ?.getOrNull(position.episodeRowIndex) - - focusedEpisode?.let { - viewModel.lookUpChosenTracks(it.id, it) - viewModel.lookupPeopleInEpisode(it) - } - } - val chosenStreams = state.chosenStreams - - val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) - val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - - when (val st = state.series) { - is DataLoadingState.Error -> { - ErrorMessage(st, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Success<BaseItem> -> { - RequestOrRestoreFocus( - when (rowFocused) { - EPISODE_ROW -> episodeRowFocusRequester - CAST_AND_CREW_ROW -> castCrewRowFocusRequester - GUEST_STAR_ROW -> guestStarRowFocusRequester - EXTRAS_ROW -> extrasRowFocusRequester - else -> episodeRowFocusRequester - }, - "series_overview", - ) - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() } } + } + } - SeriesOverviewContent( - preferences = preferences, - series = st.data, - seasons = state.seasons, - episodes = state.episodes, - chosenStreams = chosenStreams, - peopleInEpisode = state.peopleInEpisode.people, - seasonExtras = state.extras, - position = position, - firstItemFocusRequester = firstItemFocusRequester, - episodeRowFocusRequester = episodeRowFocusRequester, - castCrewRowFocusRequester = castCrewRowFocusRequester, - guestStarRowFocusRequester = guestStarRowFocusRequester, - extrasRowFocusRequester = extrasRowFocusRequester, - onChangeSeason = { index -> - if (index != position.seasonTabIndex) { - state.seasons.getOrNull(index)?.let { season -> - viewModel.loadEpisodes(season.id, season.indexNumber) - viewModel.position.update { - SeriesOverviewPosition(index, 0) + LaunchedEffect(position) { + (episodes as? EpisodeList.Success) + ?.episodes + ?.getOrNull(position.episodeRowIndex) + ?.let { + viewModel.lookUpChosenTracks(it.id, it) + } + } + val chosenStreams by viewModel.chosenStreams.observeAsState(null) + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> { + series?.let { series -> + LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } + LifecycleStartEffect(destination.itemId) { + viewModel.maybePlayThemeSong( + destination.itemId, + preferences.appPreferences.interfacePreferences.playThemeSongs, + ) + onStopOrDispose { + viewModel.release() + } + } + + fun buildMoreForEpisode( + ep: BaseItem, + fromLongClick: Boolean, + ): DialogParams = + DialogParams( + fromLongClick = fromLongClick, + title = series.name + " - " + ep.data.seasonEpisode, + items = + buildMoreDialogItems( + context = context, + item = ep, + watched = ep.data.userData?.played ?: false, + favorite = ep.data.userData?.isFavorite ?: false, + series = series, + sourceId = chosenStreams?.sourceId, + actions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched( + itemId, + watched, + position.episodeRowIndex, + ) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite( + itemId, + favorite, + position.episodeRowIndex, + ) + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = it + }, + ), + onChooseVersion = { + chooseVersion = + chooseVersionParams( + context, + ep.data.mediaSources!!, + ) { idx -> + val source = ep.data.mediaSources!![idx] + viewModel.savePlayVersion( + ep, + source.id!!.toUUID(), + ) + } + moreDialog = null + }, + onChooseTracks = { type -> + chooseSource( + ep.data, + chosenStreams?.itemPlayback, + )?.let { source -> + chooseVersion = + chooseStream( + context = context, + streams = source.mediaStreams.orEmpty(), + type = type, + onClick = { trackIndex -> + viewModel.saveTrackSelection( + ep, + chosenStreams?.itemPlayback, + trackIndex, + type, + ) + }, + ) + } + }, + ), + ) + + SeriesOverviewContent( + preferences = preferences, + series = series, + seasons = seasons, + episodes = episodes, + chosenStreams = chosenStreams, + position = position, + backdropImageUrl = + remember { + viewModel.imageUrl( + series.id, + ImageType.BACKDROP, + ) + }, + firstItemFocusRequester = firstItemFocusRequester, + episodeRowFocusRequester = episodeRowFocusRequester, + onFocus = { + if (it.seasonTabIndex != position.seasonTabIndex) { + seasons.getOrNull(it.seasonTabIndex)?.let { season -> + viewModel.loadEpisodes(season.id) } } - } - }, - onFocusEpisode = { episodeIndex -> - viewModel.position.update { - it.copy(episodeRowIndex = episodeIndex) - } - }, - onClick = { - rowFocused = EPISODE_ROW - val resumePosition = - it.data.userData - ?.playbackPositionTicks - ?.ticks ?: Duration.ZERO - viewModel.navigateTo( - Destination.Playback( - it.id, - resumePosition.inWholeMilliseconds, - ), - ) - }, - onLongClick = { ep -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = viewModel.canDelete(ep, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - playOnClick = { resume -> - rowFocused = EPISODE_ROW - episodeList?.getOrNull(position.episodeRowIndex)?.let { - viewModel.release() + position = it + }, + onClick = { + val resumePosition = + it.data.userData + ?.playbackPositionTicks + ?.ticks ?: Duration.ZERO viewModel.navigateTo( Destination.Playback( it.id, - resume.inWholeMilliseconds, + resumePosition.inWholeMilliseconds, + it, ), ) - } - }, - watchOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val played = it.data.userData?.played ?: false - viewModel.setWatched(it.id, !played, position.episodeRowIndex) - } - }, - favoriteOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val favorite = it.data.userData?.isFavorite ?: false - viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) - } - }, - moreOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = viewModel.canDelete(ep, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, + }, + onLongClick = { ep -> + moreDialog = buildMoreForEpisode(ep, true) + }, + playOnClick = { resume -> + episodeList?.getOrNull(position.episodeRowIndex)?.let { + viewModel.release() + viewModel.navigateTo( + Destination.Playback( + it.id, + resume.inWholeMilliseconds, + it, + ), ) - } - }, - overviewOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = ItemDetailsDialogInfo(it) - } - }, - personOnClick = { - rowFocused = - if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - onClickExtra = { _, extra -> - rowFocused = EXTRAS_ROW - viewModel.navigateTo(extra.destination) - }, - canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, - onConfirmDelete = viewModel::deleteItem, - onChooseVersion = contextActions.onChooseVersion, - modifier = modifier, - ) + } + }, + watchOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val played = it.data.userData?.played ?: false + viewModel.setWatched(it.id, !played, position.episodeRowIndex) + } + }, + favoriteOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val favorite = it.data.userData?.isFavorite ?: false + viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) + } + }, + moreOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> + moreDialog = buildMoreForEpisode(ep, false) + } + }, + overviewOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + files = it.data.mediaSources.orEmpty(), + ) + } + }, + modifier = modifier, + ) + } } } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = viewModel.streamChoiceService::chooseSource, - contextMenu = contextMenu, - preferredSubtitleLanguage = preferredSubtitleLanguage, - ) - } + overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - userDto + viewModel.serverRepository.currentUserDto.value ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, ) } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + chooseVersion?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { chooseVersion = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } showPlaylistDialog?.let { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), @@ -347,13 +386,7 @@ fun SeriesOverview( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, - onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } } - -private const val EPISODE_ROW = 0 -private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1 -private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1 -private const val EXTRAS_ROW = GUEST_STAR_ROW + 1 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 0759572c..5d75a081 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 @@ -1,75 +1,58 @@ package com.github.damontecres.wholphin.ui.detail.series -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -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.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha 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.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.text.style.TextOverflow 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.data.ChosenStreams -import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard -import com.github.damontecres.wholphin.ui.cards.ExtrasRow -import com.github.damontecres.wholphin.ui.cards.PersonRow +import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage 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.formatDateTime 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 @Composable @@ -78,17 +61,12 @@ fun SeriesOverviewContent( series: BaseItem, seasons: List<BaseItem?>, episodes: EpisodeList, - seasonExtras: List<ExtrasItem>, chosenStreams: ChosenStreams?, - peopleInEpisode: List<Person>, position: SeriesOverviewPosition, + backdropImageUrl: String?, firstItemFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester, - castCrewRowFocusRequester: FocusRequester, - guestStarRowFocusRequester: FocusRequester, - extrasRowFocusRequester: FocusRequester, - onChangeSeason: (Int) -> Unit, - onFocusEpisode: (Int) -> Unit, + onFocus: (SeriesOverviewPosition) -> Unit, onClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit, playOnClick: (Duration) -> Unit, @@ -96,16 +74,10 @@ fun SeriesOverviewContent( favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, overviewOnClick: () -> Unit, - personOnClick: (Person) -> Unit, - canDelete: (BaseItem) -> Boolean, - onConfirmDelete: (BaseItem) -> Unit, - onClickExtra: (Int, ExtrasItem) -> Unit, - onChooseVersion: (BaseItem, MediaSourceInfo) -> Unit, modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } - var selectedTabIndex by rememberSaveable(position.seasonTabIndex) { mutableIntStateOf(position.seasonTabIndex) } + var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } LaunchedEffect(selectedTabIndex) { logTab("series_overview", selectedTabIndex) } @@ -117,201 +89,154 @@ fun SeriesOverviewContent( var cardRowHasFocus by remember { mutableStateOf(false) } val dimming by animateFloatAsState(if (pageHasFocus && !cardRowHasFocus) .4f else 1f) - val scrollState = rememberScrollState() - val scrollConnection = rememberDelayedNestedScroll() - var requestFocusAfterSeason by remember { mutableStateOf(false) } - - val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } - val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } - val tabs = - seasons.mapIndexed { index, season -> - TabDetails( - title = - StringStringProvider( - season?.name - ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) } - ?: "", - ), - tabFocusRequester = tabFocusRequesters[index], - contentFocusRequester = contentFocusRequesters[index], - ) - } - - val currentOnChangeSeason by rememberUpdatedState(onChangeSeason) - Box( modifier = modifier - .fillMaxWidth(), + .fillMaxWidth() +// .fillMaxHeight(.33f) + .height(460.dp) + .bringIntoViewRequester(bringIntoViewRequester), ) { - Column( + DetailsBackdropImage(backdropImageUrl) + LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), modifier = Modifier - .fillMaxSize() - .padding(vertical = 16.dp) - .focusGroup() - .nestedScroll(scrollConnection) - .verticalScroll(scrollState) + .focusRestorer(episodeRowFocusRequester) .onFocusChanged { pageHasFocus = it.hasFocus }, ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier - .focusGroup() - .bringIntoViewRequester(bringIntoViewRequester), - ) { + item { val paddingValues = - remember(preferences.appPreferences.interfacePreferences.showClock) { - if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 0.dp, end = 184.dp) - } else { - PaddingValues(start = 0.dp, end = 16.dp) - } + if (preferences.appPreferences.interfacePreferences.showClock) { + PaddingValues(start = 16.dp, end = 100.dp) + } else { + PaddingValues(start = 16.dp, end = 16.dp) } TabRow( selectedTabIndex = selectedTabIndex, - tabs = tabs, - onClick = - remember { - { - selectedTabIndex = it - currentOnChangeSeason(it) - requestFocusAfterSeason = true - } + tabs = + seasons.mapNotNull { + it?.name + ?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}") }, + onClick = { + selectedTabIndex = it + onFocus.invoke(SeriesOverviewPosition(it, 0)) + }, modifier = Modifier .focusRequester(tabRowFocusRequester) + .focusRestorer() .padding(paddingValues) - .padding(bottom = 4.dp) .fillMaxWidth(), ) - TitleOrLogo( - item = series, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - modifier = Modifier.padding(start = HeaderUtils.startPadding), - ) + } + item { + series.name?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier, + ) + } + } + item { + // Episode header FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, chosenStreams = chosenStreams, overviewOnClick = overviewOnClick, - overviewOnFocus = { - if (it.isFocused) { - scope.launch { - bringIntoViewRequester.bringIntoView() - } - } - }, modifier = Modifier.fillMaxWidth(.6f), ) - -// key(position.seasonTabIndex) { - when (val eps = episodes) { - EpisodeList.Loading -> { - LoadingPage() - } - - is EpisodeList.Error -> { - ErrorMessage(eps.message, eps.exception) - } - - is EpisodeList.Success -> { - if (requestFocusAfterSeason) { - // Changing seasons, so move focus once the new episodes are loaded - LaunchedEffect(Unit) { - firstItemFocusRequester.tryRequestFocus() - requestFocusAfterSeason = false - } - } - val state = rememberLazyListState(position.episodeRowIndex) - var epPosition by rememberInt(position.episodeRowIndex) - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = - Modifier - .focusRestorer(episodeRowFocusRequester) -// .focusRequester(episodeRowFocusRequester) - .onFocusChanged { - cardRowHasFocus = it.hasFocus - }, - ) { - itemsIndexed(eps.episodes) { episodeIndex, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocusEpisode.invoke(episodeIndex) + } + item { + key(position.seasonTabIndex) { + when (val eps = episodes) { + EpisodeList.Loading -> LoadingPage() + is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception) + is EpisodeList.Success -> { + val state = rememberLazyListState() + OneTimeLaunchedEffect { + if (state.firstVisibleItemIndex != position.episodeRowIndex) { + state.scrollToItem(position.episodeRowIndex) + } + firstItemFocusRequester.tryRequestFocus() + } + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = + Modifier + .focusRestorer(firstItemFocusRequester) + .focusRequester(episodeRowFocusRequester) + .onFocusChanged { + cardRowHasFocus = it.hasFocus + }, + ) { + itemsIndexed(eps.episodes) { episodeIndex, episode -> + val interactionSource = remember { MutableInteractionSource() } + if (interactionSource.collectIsFocusedAsState().value) { + onFocus.invoke( + SeriesOverviewPosition( + selectedTabIndex, + episodeIndex, + ), + ) + } + val cornerText = + episode?.data?.indexNumber?.let { "E$it" } + ?: episode?.data?.premiereDate?.let(::formatDateTime) + BannerCard( + name = episode?.name, + imageUrl = episode?.imageUrl, + aspectRatio = + episode + ?.data + ?.primaryImageAspectRatio + ?.toFloat() + ?.coerceAtLeast(AspectRatios.FOUR_THREE) + ?: (AspectRatios.WIDE), + cornerText = cornerText, + played = episode?.data?.userData?.played ?: false, + playPercent = + episode?.data?.userData?.playedPercentage + ?: 0.0, + onClick = { if (episode != null) onClick.invoke(episode) }, + onLongClick = { + if (episode != null) { + onLongClick.invoke( + episode, + ) + } + }, + modifier = + Modifier + .ifElse( + episodeIndex == position.episodeRowIndex, + Modifier.focusRequester(firstItemFocusRequester), + ).ifElse( + episodeIndex != position.episodeRowIndex, + Modifier + .background( + Color.Black, + shape = RoundedCornerShape(8.dp), + ).alpha(dimming), + ), + interactionSource = interactionSource, + cardHeight = 120.dp, + ) } - BannerCard( - name = episode?.name, - item = episode, - aspectRatio = - episode - ?.aspectRatio - ?.coerceAtLeast(AspectRatios.FOUR_THREE) - ?: (AspectRatios.WIDE), - cornerText = episode?.ui?.episodeCornerText, - played = episode?.data?.userData?.played ?: false, - playPercent = - episode?.data?.userData?.playedPercentage - ?: 0.0, - onClick = { - epPosition = episodeIndex - if (episode != null) onClick.invoke(episode) - }, - onLongClick = { - epPosition = episodeIndex - if (episode != null) onLongClick.invoke(episode) - }, - modifier = - Modifier - .ifElse( - episodeIndex == position.episodeRowIndex, - Modifier - .focusRequester(firstItemFocusRequester), - ).ifElse( - episodeIndex == epPosition, - Modifier.focusRequester(episodeRowFocusRequester), - ).background( - if (episodeIndex != position.episodeRowIndex) { - Color.Black - } else { - Color.Transparent - }, - shape = RoundedCornerShape(8.dp), - ).graphicsLayer { - alpha = - if (episodeIndex != position.episodeRowIndex) { - dimming - } else { - 1f - } - }.onFocusChanged { - if (it.isFocused) { - scope.launch { - bringIntoViewRequester.bringIntoView() - } - } - }.onKeyEvent { - if (episode != null && isPlayKeyUp(it)) { - onClick.invoke(episode) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = interactionSource, - cardHeight = 120.dp, - useSeriesForPrimary = false, - ) } } } -// } } - + } + item { focusedEpisode?.let { ep -> FocusedEpisodeFooter( preferences = preferences, @@ -324,77 +249,13 @@ fun SeriesOverviewContent( episodeRowFocusRequester.tryRequestFocus() }, favoriteOnClick = favoriteOnClick, - buttonOnFocusChanged = { - if (it.isFocused) { - scope.launch { - bringIntoViewRequester.bringIntoView() - } - } - }, - canDelete = canDelete.invoke(ep), - onConfirmDelete = { onConfirmDelete.invoke(ep) }, - onChooseVersion = { onChooseVersion.invoke(ep, it) }, modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth(), + .fillMaxWidth() + .padding(start = 16.dp), ) } } - - val (guestStars, castAndCrew) = - remember(peopleInEpisode) { - peopleInEpisode.partition { it.type == PersonKind.GUEST_STAR } - } - - AnimatedVisibility( - visible = peopleInEpisode.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (castAndCrew.isNotEmpty()) { - PersonRow( - title = R.string.cast_and_crew, - people = castAndCrew, - onClick = personOnClick, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(castCrewRowFocusRequester), - ) - } - if (guestStars.isNotEmpty()) { - PersonRow( - title = R.string.guest_stars, - people = guestStars, - onClick = personOnClick, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(guestStarRowFocusRequester), - ) - } - } - } - AnimatedVisibility( - visible = seasonExtras.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut(), - ) { - ExtrasRow( - extras = seasonExtras, - onClickItem = onClickExtra, - onLongClickItem = { _, _ -> }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(extrasRowFocusRequester), - ) - } } } } 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 3bbc3a6b..89cd5937 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 @@ -2,74 +2,46 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context import android.widget.Toast -import androidx.lifecycle.ViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem 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.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.preferences.ThemeSongVolume +import com.github.damontecres.wholphin.preferences.UserPreferences 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.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.ItemRowFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.gt -import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.letNotEmpty -import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain 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 -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.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 -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -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.libraryApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -80,12 +52,13 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import timber.log.Timber import java.util.UUID +import javax.inject.Inject -@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class) +@HiltViewModel class SeriesViewModel - @AssistedInject + @Inject constructor( - private val api: ApiClient, + api: ApiClient, @param:ApplicationContext val context: Context, val serverRepository: ServerRepository, private val navigationManager: NavigationManager, @@ -95,215 +68,104 @@ class SeriesViewModel private val peopleFavorites: PeopleFavorites, private val trailerService: TrailerService, private val extrasService: ExtrasService, - val streamChoiceService: StreamChoiceService, - val serverReportService: ServerReportService, - private val userPreferencesService: UserPreferencesService, - private val backdropService: BackdropService, - private val seerrService: SeerrService, - private val mediaManagementService: MediaManagementService, - @Assisted val seriesId: UUID, - @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, - @Assisted val seriesPageType: SeriesPageType, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create( - seriesId: UUID, - seasonEpisodeIds: SeasonEpisodeIds?, - seriesPageType: SeriesPageType, - ): SeriesViewModel - } + ) : ItemViewModel(api) { + private lateinit var seriesId: UUID + private lateinit var prefs: UserPreferences + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) + val seasons = MutableLiveData<List<BaseItem>>(listOf()) + val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading) - private val _state = MutableStateFlow(SeriesState()) - val state: StateFlow<SeriesState> = _state + val trailers = MutableLiveData<List<Trailer>>(listOf()) + val extras = MutableLiveData<List<ExtrasItem>>(listOf()) + val people = MutableLiveData<List<Person>>(listOf()) + val similar = MutableLiveData<List<BaseItem>>() - val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) + fun init( + prefs: UserPreferences, + itemId: UUID, + seasonEpisodeIds: SeasonEpisodeIds?, + loadAdditionalDetails: Boolean, + ) { + this.seriesId = itemId + this.prefs = prefs + viewModelScope.launch( + LoadingExceptionHandler( + loading, + "Error loading series $seriesId", + ) + Dispatchers.IO, + ) { + val item = fetchItem(seriesId) + val seasons = getSeasons(item) - init { - viewModelScope.launchIO { - Timber.v("Start") - addCloseable { themeSongPlayer.stop() } - val series = - try { - api.userLibraryApi - .getItem(seriesId) - .content - .let { BaseItem(it) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error fetching series %s", seriesId) - _state.update { it.copy(series = DataLoadingState.Error(ex)) } - return@launchIO - } - viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(flowOf(series)) { canDelete -> - _state.update { it.copy(canDeleteSeries = canDelete) } - } - } - backdropService.submit(series) - - val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber) - - val episodeListDeferred = - if (seriesPageType == SeriesPageType.OVERVIEW) { - viewModelScope.async(WholphinDispatchers.IO) { - if (seasonEpisodeIds != null) { - loadEpisodesInternal( - seasonEpisodeIds.seasonId, - seasonEpisodeIds.seasonNumber, - seasonEpisodeIds.episodeId, - seasonEpisodeIds.episodeNumber, - ) - } else { - seasonsDeferred.await().firstOrNull()?.let { - loadEpisodesInternal( - it.id, - it.indexNumber, - null, - null, - ) - } ?: EpisodeList.Error(message = "Could not determine season") - } + // If a particular season was requested, fetch those episodes, otherwise get the first season + val initialSeason = + if (seasonEpisodeIds != null) { + seasons.firstOrNull { + equalsNotNull(it.id, seasonEpisodeIds.seasonId) || + equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber) } } else { - CompletableDeferred(value = EpisodeList.Loading) + seasons.firstOrNull() } - 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) { + val episodeInfo = + initialSeason?.let { + loadEpisodesInternal( + it.id, + seasonEpisodeIds?.episodeId, + seasonEpisodeIds?.episodeNumber, + ) + } ?: EpisodeList.Error("Could not determine season for selected episode") + withContext(Dispatchers.Main) { + this@SeriesViewModel.seasons.value = seasons + episodes.value = episodeInfo + loading.value = LoadingState.Success + } + if (loadAdditionalDetails) { viewModelScope.launchIO { - val index = - (seasons as? ApiRequestPager<*>)?.let { - findIndexByNumberOrIdFast( - targetNum = seasonEpisodeIds.seasonNumber, - targetId = seasonEpisodeIds.seasonId, - list = it, - parentId = null, - parentIndex = null, - ) - } ?: 0 - Timber.v("Got initial season index: $index") - position.update { - it.copy(seasonTabIndex = index.coerceAtLeast(0)) + val trailers = trailerService.getTrailers(item) + withContext(Dispatchers.Main) { + this@SeriesViewModel.trailers.value = trailers } } viewModelScope.launchIO { - val extras = extrasService.getExtras(seasonEpisodeIds.seasonId) - _state.update { it.copy(extras = extras) } - } - } - val remoteTrailers = trailerService.getRemoteTrailers(series) - this@SeriesViewModel.position.update { - it.copy( - episodeRowIndex = - (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, - ) - } - _state.update { - it.copy( - series = DataLoadingState.Success(series), - seasons = seasons, - episodes = episodes, - trailers = remoteTrailers, - ) - } - - if (seriesPageType == SeriesPageType.DETAILS) { - viewModelScope.launchIO { - trailerService.getLocalTrailers(series).letNotEmpty { localTrailers -> - _state.update { it.copy(trailers = localTrailers + remoteTrailers) } - } + val people = peopleFavorites.getPeopleFor(item) + this@SeriesViewModel.people.setValueOnMain(people) } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(series) - _state.update { it.copy(people = people) } + val extras = extrasService.getExtras(item.id) + this@SeriesViewModel.extras.setValueOnMain(extras) } - viewModelScope.launchIO { - val extras = extrasService.getExtras(series.id) - _state.update { it.copy(extras = extras) } - } - if (state.value.similar.isEmpty()) { + if (!similar.isInitialized) { viewModelScope.launchIO { val similar = api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, - itemId = seriesId, - fields = ItemRowFields, + userId = serverRepository.currentUser.value?.id, + itemId = itemId, + fields = SlimItemFields, limit = 25, ), ).content.items - .map { BaseItem(it, true) } - _state.update { it.copy(similar = similar) } + .map { BaseItem.from(it, api, true) } + this@SeriesViewModel.similar.setValueOnMain(similar) } } - viewModelScope.launchIO { - val results = seerrService.similar(series).orEmpty() - _state.update { it.copy(discovered = results) } - } - viewModelScope.launchIO { - seerrService.active.collectLatest { active -> - val tv = - if (active) { - try { - seerrService - .getTvSeries(series) - ?.let { seerrService.createDiscoverItem(it) } - } catch (ex: Exception) { - Timber.e(ex) - null - } - } else { - null - } - _state.update { it.copy(discoverSeries = tv) } - } - } - } - mediaManagementService.deletedItemFlow - .onEach { deletedItem -> - if (deletedItem.item.data.seriesId == seriesId) { - Timber.d( - "Item %s deleted from series %s", - deletedItem.item.id, - seriesId, - ) - val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await() - _state.update { it.copy(seasons = seasons) } - } - }.catch { ex -> - Timber.e(ex, "Error refreshing after deleted item") - }.launchIn(viewModelScope) - } - } - - fun onResumePage() { - state.value.series.successValue?.let { item -> - viewModelScope.launchDefault { backdropService.submit(item) } - viewModelScope.launchDefault { - themeSongPlayer.playThemeFor(seriesId) } } } - fun refresh() { - state.value.series.successValue?.let { item -> - viewModelScope.launchIO { - (state.value.seasons as? ApiRequestPager<*>)?.refresh() + /** + * If the series has a theme song & app settings allow, play it + */ + fun maybePlayThemeSong( + seriesId: UUID, + playThemeSongs: ThemeSongVolume, + ) { + viewModelScope.launchIO { + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + addCloseable { + themeSongPlayer.stop() } } } @@ -312,46 +174,34 @@ class SeriesViewModel themeSongPlayer.stop() } - private fun getSeasons( - series: BaseItem, - seasonNum: Int?, - ): Deferred<List<BaseItem?>> = - viewModelScope.async(WholphinDispatchers.IO) { - Timber.v("getSeasons for %s", series.id) - val request = - GetItemsRequest( - parentId = series.id, - recursive = false, - includeItemTypes = listOf(BaseItemKind.SEASON), - sortBy = listOf(ItemSortBy.INDEX_NUMBER), - sortOrder = listOf(SortOrder.ASCENDING), - enableUserData = seriesPageType == SeriesPageType.DETAILS, - fields = - if (seriesPageType == SeriesPageType.DETAILS) { - listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CAN_DELETE, - ) - } else { - listOf( - ItemFields.CAN_DELETE, - ) - }, - ) - val pager = - ApiRequestPager( + private suspend fun getSeasons(series: BaseItem): List<BaseItem> { + val request = + GetItemsRequest( + parentId = series.id, + recursive = false, + includeItemTypes = listOf(BaseItemKind.SEASON), + sortBy = listOf(ItemSortBy.INDEX_NUMBER), + sortOrder = listOf(SortOrder.ASCENDING), + fields = + listOf( + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CHILD_COUNT, + ItemFields.SEASON_USER_DATA, + ), + ) + val seasons = + GetItemsRequestHandler.execute(api, request).content.items.map { + BaseItem.from( + it, api, - request, - GetItemsRequestHandler, - viewModelScope, - pageSize = 20, - ).init(seasonNum ?: 0) - pager - } + ) + } + Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") + return seasons + } private suspend fun loadEpisodesInternal( seasonId: UUID, - seasonNum: Int?, episodeId: UUID?, episodeNumber: Int?, ): EpisodeList { @@ -363,68 +213,46 @@ class SeriesViewModel fields = listOf( ItemFields.MEDIA_SOURCES, - ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, + ItemFields.TRICKPLAY, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CAN_DELETE, - ItemFields.PARENT_ID, ), ) - Timber.v( - "loadEpisodesInternal: episodeId=%s, episodeNumber=%s", - episodeId, - episodeNumber, - ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) - pager.init(episodeNumber ?: 0) + pager.init() val initialIndex = if (episodeId != null || episodeNumber != null) { - findIndexByNumberOrIdFast( - targetNum = episodeNumber, - targetId = episodeId, - list = pager, - parentId = seasonId, - parentIndex = seasonNum, - ).coerceAtLeast(0) + pager + .indexOfBlocking { + equalsNotNull(it?.id, episodeId) || + equalsNotNull(it?.indexNumber, episodeNumber) + }.coerceAtLeast(0) } else { - // Force the first page to be fetched + // Force the first page to to be fetched if (pager.isNotEmpty()) { pager.getBlocking(0) } 0 } Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex") - return EpisodeList.Success(seasonId, pager, initialIndex) + return EpisodeList.Success(pager, initialIndex) } - fun loadEpisodes( - seasonId: UUID, - seasonNum: Int?, - ) { - val currentEpisodes = (state.value.episodes as? EpisodeList.Success) - if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - _state.update { - it.copy( - peopleInEpisode = PeopleInItem(), - episodes = EpisodeList.Loading, - extras = emptyList(), - ) - } - } + fun loadEpisodes(seasonId: UUID) { + this@SeriesViewModel.episodes.value = EpisodeList.Loading viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = try { - loadEpisodesInternal(seasonId, seasonNum, null, null) + loadEpisodesInternal(seasonId, null, null) } catch (e: Exception) { Timber.e(e, "Error loading episodes for $seriesId for season $seasonId") EpisodeList.Error(e) } - _state.update { it.copy(episodes = episodes) } - } - viewModelScope.launchIO { - val extras = extrasService.getExtras(seasonId) - _state.update { it.copy(extras = extras) } + withContext(Dispatchers.Main) { + this@SeriesViewModel.episodes.value = episodes + } } } @@ -432,83 +260,66 @@ class SeriesViewModel itemId: UUID, played: Boolean, listIndex: Int?, - ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { favoriteWatchManager.setWatched(itemId, played) listIndex?.let { refreshEpisode(itemId, listIndex) } } - private fun updateSeries() { - viewModelScope.launchIO { - try { - val series = - api.userLibraryApi - .getItem(seriesId) - .content - .let(::BaseItem) - _state.update { it.copy(series = DataLoadingState.Success(series)) } - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(series) - _state.update { it.copy(people = people) } - } - viewModelScope.launchIO { - val seasons = getSeasons(series, null).await() - _state.update { it.copy(seasons = seasons) } - } - } catch (ex: Exception) { - Timber.e(ex, "Error updating series") - showToast(context, "Error updating series") - } - } - } - fun setFavorite( itemId: UUID, favorite: Boolean, listIndex: Int?, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { - updateSeries() + val item = fetchItem(seriesId) + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(item) + this@SeriesViewModel.people.setValueOnMain(people) + } } } fun setSeasonWatched( seasonId: UUID, played: Boolean, - ) = viewModelScope.launch(WholphinDispatchers.IO + ExceptionHandler()) { + ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) - updateSeries() + val series = fetchItem(seriesId) + val seasons = getSeasons(series) + this@SeriesViewModel.seasons.setValueOnMain(seasons) } fun setWatchedSeries(played: Boolean) = - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) - updateSeries() + val series = fetchItem(seriesId) + val seasons = getSeasons(series) + this@SeriesViewModel.seasons.setValueOnMain(seasons) } fun refreshEpisode( itemId: UUID, listIndex: Int, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - val eps = state.value.episodes + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + val eps = episodes.value if (eps is EpisodeList.Success) { eps.episodes.refreshItem(listIndex, itemId) - _state.update { it.copy(episodes = eps) } + withContext(Dispatchers.Main) { + episodes.value = eps + } } - // Kind of hack to ensure the backdrop is reloaded if needed - state.value.series.successValue - ?.let { backdropService.submit(it) } } /** * Play whichever episode is next up for series or else the first episode */ fun playNextUp() { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { val result by api.tvShowsApi.getNextUp(seriesId = seriesId) val nextUp = result.items.firstOrNull() ?: api.tvShowsApi @@ -518,8 +329,8 @@ class SeriesViewModel ).content.items .firstOrNull() if (nextUp != null) { - withContext(WholphinDispatchers.Main) { - navigateTo(Destination.Playback(BaseItem(nextUp))) + withContext(Dispatchers.Main) { + navigateTo(Destination.Playback(nextUp.id, 0L)) } } else { showToast( @@ -536,6 +347,7 @@ class SeriesViewModel navigationManager.navigateTo(destination) } + val chosenStreams = MutableLiveData<ChosenStreams?>(null) private var chosenStreamsJob: Job? = null fun lookUpChosenTracks( @@ -545,13 +357,10 @@ class SeriesViewModel chosenStreamsJob?.cancel() chosenStreamsJob = viewModelScope.launchIO { - val result = - itemPlaybackRepository.getSelectedTracks( - itemId, - item, - userPreferencesService.getCurrent(), - ) - _state.update { it.copy(chosenStreams = result) } + val result = itemPlaybackRepository.getSelectedTracks(itemId, item) + withContext(Dispatchers.Main) { + chosenStreams.value = result + } } } @@ -560,13 +369,14 @@ class SeriesViewModel sourceId: UUID, ) { viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result) } - _state.update { it.copy(chosenStreams = chosen) } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen + } } } @@ -577,7 +387,6 @@ class SeriesViewModel type: MediaStreamType, ) { viewModelScope.launchIO { - val prefs = userPreferencesService.getCurrent() val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -585,118 +394,15 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) - _state.update { it.copy(chosenStreams = chosen) } - } - } - - private var peopleInEpisodeJob: Job? = null - private val peopleInEpisodeCache = - CacheBuilder - .newBuilder() - .maximumSize(25) - .build<UUID, Deferred<PeopleInItem>>() - - suspend fun lookupPeopleInEpisode(item: BaseItem) { - peopleInEpisodeJob?.cancel() - if (state.value.peopleInEpisode.itemId != item.id) { - _state.update { it.copy(peopleInEpisode = PeopleInItem()) } - val result = - peopleInEpisodeCache - .get(item.id) { - viewModelScope.async(WholphinDispatchers.IO) { - val list = - api.userLibraryApi - .getItem(item.id) - .content.people - ?.map { Person.fromDto(context, it, api) } - .orEmpty() - - PeopleInItem(item.id, list) - } - } - peopleInEpisodeJob = - viewModelScope.launch(ExceptionHandler()) { - delay(250) - val peopleInEpisode = result.await() - _state.update { it.copy(peopleInEpisode = peopleInEpisode) } - } - } - } - - fun clearChosenStreams( - item: BaseItem, - chosenStreams: ChosenStreams?, - ) { - viewModelScope.launchIO { - itemPlaybackRepository.deleteChosenStreams(chosenStreams) - lookUpChosenTracks(item.id, item) - } - } - - fun deleteItem(item: BaseItem) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - if (item.type == BaseItemKind.SERIES) { - navigationManager.goBack() - } else if (seriesPageType == SeriesPageType.DETAILS) { - state.value.series.successValue?.let { series -> - val seasons = getSeasons(series, null).await() - if (seasons.isEmpty()) { - navigationManager.goBack() - } else { - _state.update { it.copy(seasons = seasons) } - } - } - } else { - position.value.let { (_, episodeIndex) -> - val eps = state.value.episodes as? EpisodeList.Success - if (eps != null) { - val pager = eps.episodes - val lastIndex = pager.lastIndex - pager.refreshPagesAfter(episodeIndex) - if (pager.isEmpty()) { - navigationManager.goBack() - } else { - if (episodeIndex == lastIndex) { - // Deleted last episode, so need to move left - _state.update { - it.copy( - episodes = - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex - 1, - ), - ) - } - position.update { it.copy(episodeRowIndex = episodeIndex - 1) } - } else { - _state.update { - it.copy( - episodes = - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex, - ), - ) - } - } - } - } - } + val chosen = + result?.let { + itemPlaybackRepository.getChosenItemFromPlayback(item, result) } + withContext(Dispatchers.Main) { + chosenStreams.value = chosen } } } - - suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { @@ -710,186 +416,7 @@ sealed interface EpisodeList { } data class Success( - val seasonId: UUID, val episodes: ApiRequestPager<GetEpisodesRequest>, - val initialEpisodeIndex: Int, + val initialIndex: Int, ) : EpisodeList } - -data class PeopleInItem( - val itemId: UUID? = null, - val people: List<Person> = listOf(), -) - -enum class SeriesPageType { - DETAILS, - OVERVIEW, -} - -private fun checkNumberOrId( - targetNum: Int?, - targetId: UUID?, - 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 || listIndex !in list.indices)) { - // No hint info, so have to check everything - 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(), - val episodes: EpisodeList = EpisodeList.Loading, - val trailers: List<Trailer> = emptyList(), - val extras: List<ExtrasItem> = emptyList(), - val people: List<Person> = emptyList(), - val similar: List<BaseItem> = emptyList(), - val canDeleteSeries: Boolean = false, - val peopleInEpisode: PeopleInItem = PeopleInItem(), - val discovered: List<DiscoverItem> = emptyList(), - val discoverSeries: DiscoverItem? = null, - /** Seerr ratings, null until they load or if unavailable */ - val chosenStreams: ChosenStreams? = null, -) 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 deleted file mode 100644 index b0d49985..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverFocusedItem.kt +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index c61c0799..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGenreRow.kt +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 2064faa1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverGridHeader.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 6eff501e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.github.damontecres.wholphin.ui.discover - -import androidx.compose.foundation.layout.fillMaxSize -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.compose.ui.focus.focusRequester -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.ErrorMessage -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.util.DiscoverRequestType - -@Composable -fun DiscoverPage( - preferences: UserPreferences, - modifier: Modifier = Modifier, -) { - val tabs = - 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), - ) - } - 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() - .focusRequester(tabDetails.contentFocusRequester), - ) - } - - // Requests - 1 -> { - SeerrRequestsPage( - focusRequesterOnEmpty = tabDetails.tabFocusRequester, - positionCallback = { columns, index -> showHeader = index < columns }, - modifier = - Modifier - .fillMaxSize() - .focusRequester(tabDetails.contentFocusRequester), - ) - } - - // Search - 2 -> { - DiscoverSearchPage( - preferences = preferences, - 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 $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 deleted file mode 100644 index fe6dd475..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRequestGrid.kt +++ /dev/null @@ -1,329 +0,0 @@ -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.LoadingPage -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.launchDefault -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.DiscoverMovieRequestHandler -import com.github.damontecres.wholphin.util.DiscoverRequestPager -import com.github.damontecres.wholphin.util.DiscoverRequestType -import com.github.damontecres.wholphin.util.DiscoverTvRequestHandler -import com.github.damontecres.wholphin.util.TrendingRequestHandler -import com.github.damontecres.wholphin.util.UpcomingMovieRequestHandler -import com.github.damontecres.wholphin.util.UpcomingTvRequestHandler -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.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import timber.log.Timber - -@HiltViewModel(assistedFactory = DiscoverRequestViewModel.Factory::class) -class DiscoverRequestViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - 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 - } - - private val _state = MutableStateFlow(DiscoverRequestState()) - val state: StateFlow<DiscoverRequestState> = _state - - init { - viewModelScope.launchDefault { - 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() - } - 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>( - key = viewModelKey, - creationCallback = { - it.create( - destination.type, - destination.startIndex, - destination.initialFilter, - ) - }, - ), -) { - val state by viewModel.state.collectAsState() - - 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.Error -> { - ErrorMessage(s, Modifier) - } - - is DataLoadingState.Success<List<DiscoverItem?>> -> { - val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - CardGrid( - initialPosition = state.startIndex, - pager = s.data, - onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onClickPlay = { _, _ -> }, - letterPosition = { 0 }, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, - showLetterButtons = false, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - modifier = mod, - 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/DiscoverRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt deleted file mode 100644 index bbf1dc18..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverRow.kt +++ /dev/null @@ -1,194 +0,0 @@ -package com.github.damontecres.wholphin.ui.discover - -import androidx.compose.foundation.focusGroup -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.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -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.data.model.DiscoverItem -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.cards.ItemRowTitle -import com.github.damontecres.wholphin.ui.cards.ViewMoreCard -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState - -@Composable -fun DiscoverRow( - row: DiscoverRowData, - onClickItem: (Int, DiscoverItem) -> Unit, - onLongClickItem: (Int, DiscoverItem) -> Unit, - onCardFocus: (Int) -> Unit, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, - enableViewMore: Boolean = false, - onClickViewMore: () -> Unit = {}, -) { - when (val state = row.items) { - is DataLoadingState.Error -> { - ErrorMessage(state.message, state.exception, modifier) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = row.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<DiscoverItem>> -> { - DiscoverItemRow( - title = row.title.getString(), - items = state.data, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onCardFocus = onCardFocus, - enableViewMore = enableViewMore, - onClickViewMore = onClickViewMore, - modifier = modifier.focusRequester(focusRequester), - ) - } - } -} - -@Composable -fun DiscoverItemRow( - title: String, - items: List<DiscoverItem?>, - onClickItem: (Int, DiscoverItem) -> Unit, - onLongClickItem: (Int, DiscoverItem) -> Unit, - onCardFocus: (Int) -> Unit, - enableViewMore: Boolean, - modifier: Modifier = Modifier, - horizontalPadding: Dp = 16.dp, - onClickViewMore: () -> Unit = {}, -) { - val state = rememberLazyListState() - val firstFocus = remember { FocusRequester() } - val focusRequester = remember { FocusRequester() } - var position by rememberInt() - - val currentOnClickItem by rememberUpdatedState(onClickItem) - val currentOnLongClickItem by rememberUpdatedState(onLongClickItem) - val currentOnCardFocus by rememberUpdatedState(onCardFocus) - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier.focusProperties { - onEnter = { - focusRequester.tryRequestFocus() - } - }, - ) { - ItemRowTitle(title) - - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(horizontalPadding), - contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRestorer(firstFocus) - .focusRequester(focusRequester), - ) { - itemsIndexed(items) { index, item -> - val cardModifier = - remember(index, position) { - if (index == position) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - }.onFocusChanged { - if (it.isFocused) { - currentOnCardFocus.invoke(index) - } - } - } - - val onClick = - remember(index, item) { - { - position = index - if (item != null) currentOnClickItem(index, item) - } - } - - val onLongClick = - remember(index, item) { - { - position = index - if (item != null) currentOnLongClickItem(index, item) - } - } - - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = cardModifier, - ) - } - if (enableViewMore) { - item { - ViewMoreCard( - onClick = - remember { - { - position = items.size - onClickViewMore.invoke() - } - }, - onLongClick = {}, - modifier = - Modifier - .ifElse(items.size == position, Modifier.focusRequester(firstFocus)) - .onFocusChanged { - if (it.isFocused) { - currentOnCardFocus.invoke(items.size) - } - }, - ) - } - } - } - } -} 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 deleted file mode 100644 index fccf32ac..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ /dev/null @@ -1,261 +0,0 @@ -package com.github.damontecres.wholphin.ui.discover - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.preferences.UserPreferences -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.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.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 -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class DiscoverSearchViewModel - @Inject - constructor( - val navigationManager: NavigationManager, - private val seerrService: SeerrService, - val voiceInputManager: VoiceInputManager, - val userPreferencesService: UserPreferencesService, - ) : ViewModel() { - val seerrResults = MutableStateFlow<SearchResult>(SearchResult.NoQuery) - - private var searchJob: Job? = null - var currentQuery: String = "" - - fun search(query: String) { - if (query == currentQuery) { - return - } - currentQuery = query - viewModelScope.launchIO { - if (query.isBlank()) { - seerrResults.value = SearchResult.NoQuery - return@launchIO - } - Timber.v("Starting seerr search") - seerrResults.value = SearchResult.Searching - 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) - } - } - } - } - -@Composable -fun DiscoverSearchPage( - preferences: UserPreferences, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, - viewModel: DiscoverSearchViewModel = hiltViewModel(), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val seerrResults by viewModel.seerrResults.collectAsState() - var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } - var query by rememberSaveable { mutableStateOf(viewModel.currentQuery) } - var position by rememberInt(-1) - - // Start with current preferences, but collect updates when view options change - val prefs = - viewModel.userPreferencesService.flow - .collectAsState(preferences) - .value.appPreferences.interfacePreferences.searchPreferences - - 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) - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - viewModel.search(searchQuery) - } - - LaunchedEffect(query) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750L) - viewModel.search(query) - } - } - } - val gridFocusRequester = remember { FocusRequester() } - val textFieldFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (position >= 0) { - gridFocusRequester.tryRequestFocus("grid") - } else { - textFieldFocusRequester.tryRequestFocus("textField") - } - } - - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - var isSearchActive by remember { mutableStateOf(false) } - var isTextFieldFocused by remember { mutableStateOf(false) } - - 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) - .focusGroup() - .focusRestorer() - .focusRequester(textFieldFocusRequester), - ) { - AnimatedVisibility( - visible = prefs.showVoiceSearchButton, - 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 - .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 - } - }, - ) - } - } - SearchCombinedResults( - result = seerrResults, - focusRequester = gridFocusRequester, - onClickItem = { _, _ -> }, - onLongClickItem = { _, _ -> }, - onPlayItem = { _, _ -> }, - onClickPosition = { position = it.column }, - onClickDiscover = onClickDiscover, - 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 deleted file mode 100644 index 62075882..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ /dev/null @@ -1,568 +0,0 @@ -package com.github.damontecres.wholphin.ui.discover - -import android.content.Context -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -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.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.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.focus.focusRestorer -import androidx.compose.ui.platform.LocalDensity -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.filter.DiscoverFilter -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.data.model.SourceRatings -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.SeerrRatingsService -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.launchIO -import com.github.damontecres.wholphin.ui.listToDotString -import com.github.damontecres.wholphin.ui.main.HomePageHeader -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 dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Job -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 - -@HiltViewModel -class SeerrDiscoverViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val seerrService: SeerrService, - private val ratingsService: SeerrRatingsService, - val navigationManager: NavigationManager, - private val backdropService: BackdropService, - ) : ViewModel() { - val state = MutableStateFlow<DiscoverState>(DiscoverState()) - val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf()) - - init { - viewModelScope.launchIO { - backdropService.clearBackdrop() - } - fetchAndUpdateState(seerrService::discoverMovies) { - this.copy( - movies = - DiscoverRowData( - ResStringProvider(R.string.movies_title), - it, - DiscoverRequestType.DISCOVER_MOVIES, - ), - ) - } - fetchAndUpdateState(seerrService::discoverTv) { - this.copy( - tv = - DiscoverRowData( - ResStringProvider(R.string.tv_shows_title), - it, - DiscoverRequestType.DISCOVER_TV, - ), - ) - } - fetchAndUpdateState(seerrService::trending) { - this.copy( - trending = - DiscoverRowData( - ResStringProvider(R.string.trending), - it, - DiscoverRequestType.TRENDING, - ), - ) - } - fetchAndUpdateState(seerrService::upcomingMovies) { - this.copy( - upcomingMovies = - DiscoverRowData( - ResStringProvider(R.string.upcoming_movies), - it, - DiscoverRequestType.UPCOMING_MOVIES, - ), - ) - } - fetchAndUpdateState(seerrService::upcomingTv) { - this.copy( - upcomingTv = - DiscoverRowData( - ResStringProvider(R.string.upcoming_tv), - it, - DiscoverRequestType.UPCOMING_TV, - ), - ) - } - 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( - getData: suspend () -> List<DiscoverItem>, - copyFunc: DiscoverState.(DataLoadingState<List<DiscoverItem>>) -> DiscoverState, - ) { - viewModelScope.launchIO { - state.update { - copyFunc.invoke(it, DataLoadingState.Loading) - } - try { - val results = getData.invoke() - state.update { - copyFunc.invoke(it, DataLoadingState.Success(results)) - } - } catch (ex: Exception) { - state.update { - copyFunc.invoke(it, DataLoadingState.Error(ex)) - } - } - } - } - - fun updateBackdrop(item: DiscoverFocusedItem?) { - viewModelScope.launchIO { - if (item != null) { - backdropService.submit("discover_${item.id}", item.backDropUrl) - if (item is DiscoverFocusedItem.Item) { - fetchRating(item.id, item.item.type) - } - } else { - backdropService.clearBackdrop() - } - } - } - - private var ratingJob: Job? = null - - /** - * Fetch the ratings for the focused item, cancelling any previous request that is still running - */ - fun fetchRating( - id: Int, - type: SeerrItemType, - ) { - val key = type to id - if (key in rating.value) return - ratingJob?.cancel() - ratingJob = - viewModelScope.launchIO { - val result = ratingsService.get(type, id) ?: return@launchIO - rating.update { it + (key to result) } - } - } - } - -fun Int.toUUID() = UUID(0L, toLong()) - -fun UUID.toInt() = leastSignificantBits.toInt() - -data class DiscoverRowData( - val title: StringProvider, - val items: DataLoadingState<List<DiscoverItem>>, - val type: DiscoverRequestType, -) { - companion object { - val EMPTY = - DiscoverRowData( - EmptyStringProvider, - DataLoadingState.Pending, - DiscoverRequestType.UNKNOWN, - ) - } -} - -data class DiscoverState( - val movies: DiscoverRowData = DiscoverRowData.EMPTY, - val tv: DiscoverRowData = DiscoverRowData.EMPTY, - 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 ratingMap by viewModel.rating.collectAsState() - - val focusRequesters = remember { List(LAST_ROW + 1) { FocusRequester() } } - var position by rememberPosition(0, -1) - val focusedItem: DiscoverFocusedItem? = - remember(position) { - position.let { - 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) { - viewModel.updateBackdrop(focusedItem) - } - var firstFocused by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(state.trending) { - if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { - firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true - } else if (firstFocused) { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - } - - Column( - modifier = modifier, - ) { - val details = - remember(focusedItem) { - (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> - buildList { - focusedItem.item - .releaseDate - ?.year - ?.toString() - ?.let(::add) - }.let { - QuickDetailsData(listToDotString(it)) - } - } - } - val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] } - HomePageHeader( - title = focusedItem?.title?.getString(), - subtitle = focusedItem?.subtitle, - overview = focusedItem?.overview, - overviewTwoLines = false, - quickDetails = details, - ratings = focusedRatings, - timeRemaining = null, - endsAt = null, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - logoImageUrl = focusedItem?.logoUrl, - modifier = - Modifier - .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) { - // The size of the row titles & spacing - 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), - ) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), - ) { - item { discoverItemRow(ROW_TRENDING, state.trending) } - - // Movies - item { discoverItemRow(ROW_MOVIES, state.movies) } - item { - CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { - 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( - type = DiscoverRequestType.DISCOVER_MOVIES, - initialFilter = - DiscoverFilter( - genreIds = listOf(genre.id.toInt()), - ), - titleOverride = - discoverGenreTitle( - genre.name, - SeerrItemType.MOVIE, - ), - startIndex = 0, - ), - ) - }, - 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 deleted file mode 100644 index 52815683..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.github.damontecres.wholphin.ui.discover - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -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.api.seerr.model.MediaRequest -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -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.cards.DiscoverItemCard -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.detail.CardGrid -import com.github.damontecres.wholphin.ui.detail.CardGridItem -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.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class SeerrRequestsViewModel - @Inject - constructor( - private val seerrServerRepository: SeerrServerRepository, - private val seerrService: SeerrService, - val navigationManager: NavigationManager, - private val backdropService: BackdropService, - ) : ViewModel() { - val state = MutableStateFlow(SeerrRequestsState.EMPTY) - - init { - viewModelScope.launchIO { - backdropService.clearBackdrop() - } - seerrServerRepository.connection - .onEach { user -> - state.update { it.copy(requests = DataLoadingState.Loading) } - if (user != null) { - val semaphore = Semaphore(3) - val mediaRequests = - seerrService.api.requestApi - .requestGet() - .results - .orEmpty() - val requests = - mediaRequests.mapNotNull { request -> - if (request.media?.tmdbId != null) { - viewModelScope.async(WholphinDispatchers.IO) { - semaphore.withPermit { - val type = SeerrItemType.fromString(request.type) - when (type) { - SeerrItemType.MOVIE -> { - seerrService.api.moviesApi - .movieMovieIdGet( - movieId = request.media.tmdbId, - ).let { seerrService.createDiscoverItem(it) } - } - - SeerrItemType.TV -> { - seerrService.api.tvApi - .tvTvIdGet(tvId = request.media.tmdbId) - .let { seerrService.createDiscoverItem(it) } - } - - SeerrItemType.PERSON -> { - null - } - - SeerrItemType.UNKNOWN -> { - null - } - }?.let { RequestGridItem(request, it) } - } - } - } else { - Timber.v("No TMDB ID for request %s", request.id) - null - } - } - val results = requests.awaitAll().filterNotNull() - - state.update { it.copy(requests = DataLoadingState.Success(results)) } - } - }.catch { ex -> - Timber.e(ex, "Error fetching requests") - state.update { it.copy(requests = DataLoadingState.Error(ex)) } - }.launchIn(viewModelScope) - } - - fun updateBackdrop(item: DiscoverItem?) { - viewModelScope.launchIO { - if (item != null) { - backdropService.submit("discover_${item.id}", item.backDropUrl) - } - } - } - } - -data class SeerrRequestsState( - val requests: DataLoadingState<List<RequestGridItem>>, -) { - companion object { - val EMPTY = SeerrRequestsState(DataLoadingState.Pending) - } -} - -data class RequestGridItem( - val request: MediaRequest, - val item: DiscoverItem, -) : CardGridItem { - override val gridId: String = request.id.toString() - override val playable: Boolean = false - override val sortName: String = request.updatedAt ?: "0000" -} - -@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 -> { - ErrorMessage(state.message, state.exception, modifier.focusable()) - } - - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier.focusable()) - } - - is DataLoadingState.Success<List<RequestGridItem>> -> { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (state.data.isNotEmpty()) { - focusRequester.tryRequestFocus() - } else { - focusRequesterOnEmpty?.tryRequestFocus() - } - } - Column(modifier = modifier) { -// Text( -// text = stringResource(R.string.request), -// style = MaterialTheme.typography.displaySmall, -// color = MaterialTheme.colorScheme.onBackground, -// textAlign = TextAlign.Center, -// modifier = Modifier.fillMaxWidth(), -// ) - if (state.data.isEmpty()) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxSize(), - ) - } else { - CardGrid( - pager = state.data, - onClickItem = { index: Int, item: RequestGridItem -> - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) - }, - onLongClickItem = { index: Int, item: RequestGridItem -> - }, - onClickPlay = { _, item -> - }, - letterPosition = { c: Char -> 0 }, - gridFocusRequester = focusRequester, - showJumpButtons = false, - showLetterButtons = false, - spacing = 16.dp, - cardContent = @Composable { (item, index, onClick, onLongClick, widthPx, mod) -> - DiscoverItemCard( - item = item?.item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - 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 26cc96d5..249b8f13 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,101 +1,87 @@ package com.github.damontecres.wholphin.ui.main -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import android.widget.Toast 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.Spacer +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.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.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 coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.transitionFactory import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.data.model.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.cards.BannerCard -import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle -import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.StudioCard -import com.github.damontecres.wholphin.ui.cards.ViewMoreCard import com.github.damontecres.wholphin.ui.components.CircularProgress -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.EpisodeName +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.FocusableItemRow -import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.components.TitleOrLogo -import com.github.damontecres.wholphin.ui.components.rememberLogoUrl -import com.github.damontecres.wholphin.ui.components.rememberSourceRatings 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.RowColumn +import com.github.damontecres.wholphin.ui.data.RowColumnSaver +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp -import com.github.damontecres.wholphin.ui.playback.playable -import com.github.damontecres.wholphin.ui.playback.scale -import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay -import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID -import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun HomePage( @@ -105,144 +91,84 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LifecycleStartEffect(Unit) { - viewModel.init() - onStopOrDispose { } + var firstLoad by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(Unit) { + viewModel.init(preferences).join() + firstLoad = false + } + val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) + val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) + val watchingRows by viewModel.watchingRows.observeAsState(listOf()) + val latestRows by viewModel.latestRows.observeAsState(listOf()) + LaunchedEffect(loading) { + val state = loading + if (!firstLoad && state is LoadingState.Error) { + // After the first load, refreshes occur in the background and an ErrorMessage won't show + // So send a Toast on errors instead + Toast + .makeText( + context, + "Home refresh error: ${state.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + } } - val state by viewModel.state.collectAsState() - val loading = state.loadingState - val refreshing = state.refreshState - val homeRows = state.homeRows when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) - } + is LoadingState.Error -> ErrorMessage(state) LoadingState.Loading, LoadingState.Pending, - -> { - LoadingPage(modifier) - } + -> LoadingPage() LoadingState.Success -> { - var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) } + var dialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } - var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } - - val playlistState by playlistViewModel.playlistState.collectAsState() - var position by rememberPosition() - - val onFocusPosition = remember { { it: RowColumn -> position = it } } - val currentHomePrefs by rememberUpdatedState(preferences.appPreferences.homePagePreferences) - val onClickItem = - remember { - { clickedPosition: RowColumn, item: BaseItem -> - position = clickedPosition - if (currentHomePrefs.clickToPlay && - homeRows.getOrNull(clickedPosition.row)?.isContinueWatchingNextUp == true - ) { - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - } else { - viewModel.navigationManager.navigateTo(item.destination()) - } - } - } - val onLongClickItem = - remember { - { clickedPosition: RowColumn, item: BaseItem -> - position = clickedPosition - val row = - (homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success) - val canRemoveContinueWatching = - row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined - val canRemoveNextUp = - row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = - viewModel.canDelete( - item, - preferences.appPreferences, - ), - canRemoveContinueWatching = canRemoveContinueWatching, - canRemoveNextUp = canRemoveNextUp, - actions = - ContextMenuActions( - navigateTo = viewModel.navigationManager::navigateTo, - onClickWatch = viewModel::setWatched, - onClickFavorite = viewModel::setFavorite, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists() - showPlaylistDialog = itemId - }, - onSendMediaInfo = viewModel.serverReportService::sendMediaReportFor, - onDeleteItem = { - viewModel.deleteItem(position, it) - }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { - // Not supported on this page - }, - onShowOverview = { - overviewDialog = ItemDetailsDialogInfo(it) - }, - onClearChosenStreams = {}, - onClickRemoveFromNextUp = viewModel::removeFromNextUp, - ), - ) - } - } - val onClickPlay = - remember { - { _: RowColumn, item: BaseItem -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - } - } - val onClickViewMore = - remember { - { _: RowColumn, row: HomeRowLoadingState.Success -> - viewModel.navigationManager.navigateTo( - Destination.MoreHomeRow(row.title, row.rowType!!, row.items.size), - ) - } - } - + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( - homeRows = homeRows, - position = position, - onFocusPosition = onFocusPosition, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, + watchingRows + latestRows, + onClickItem = { position, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { position, item -> + val dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = viewModel.navigationManager::navigateTo, + onClickWatch = { itemId, played -> + viewModel.setWatched(itemId, played) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = itemId + }, + ), + ) + dialog = + DialogParams( + title = item.title ?: "", + fromLongClick = true, + items = dialogItems, + ) + }, loadingState = refreshing, showClock = preferences.appPreferences.interfacePreferences.showClock, - onUpdateBackdrop = viewModel::updateBackdrop, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - showViewMore = true, - onClickViewMore = onClickViewMore, modifier = modifier, ) - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = false, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, + dialog?.let { params -> + DialogPopup( + params = params, + onDismissRequest = { dialog = null }, ) } showPlaylistDialog?.let { itemId -> @@ -259,7 +185,6 @@ fun HomePage( playlistViewModel.createPlaylistAndAddItem(it, itemId) showPlaylistDialog = null }, - onSearch = playlistViewModel::loadPlaylists, elevation = 3.dp, ) } @@ -267,253 +192,228 @@ fun HomePage( } } -val HomeRowLoadingState?.isContinueWatchingNextUp: Boolean - get() = - (this as? HomeRowLoadingState.Success).let { row -> - row?.rowType is HomeRowConfig.ContinueWatching || - row?.rowType is HomeRowConfig.NextUp || - row?.rowType is HomeRowConfig.ContinueWatchingCombined - } - -@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List<HomeRowLoadingState>, - position: RowColumn, - onFocusPosition: (RowColumn) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, - onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, - onUpdateBackdrop: (BaseItem) -> Unit, - showLogo: Boolean, - showViewMore: Boolean, modifier: Modifier = Modifier, + onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, - listState: LazyListState = rememberLazyListState(), - takeFocus: Boolean = true, - showEmptyRows: Boolean = false, - headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> - HomePageHeader( - item = focusedItem, - showLogo = showLogo, - modifier = HeaderUtils.modifier, - ) - }, - onClickViewMore: (RowColumn, HomeRowLoadingState.Success) -> Unit = { _, _ -> }, ) { - val focusedItem = - remember(homeRows, position) { - (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull( - position.column, - ) + val context = LocalContext.current + val scope = rememberCoroutineScope() + val firstRow = + remember { + homeRows + .indexOfFirst { + when (it) { + is HomeRowLoadingState.Error -> false + is HomeRowLoadingState.Loading -> true + is HomeRowLoadingState.Pending -> true + is HomeRowLoadingState.Success -> it.items.isNotEmpty() + } + }.coerceAtLeast(0) + } + var position by rememberSaveable(stateSaver = RowColumnSaver) { + mutableStateOf(RowColumn(firstRow, 0)) + } + var focusedItem = + position.let { + (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } - val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } - var firstFocused by remember { mutableStateOf(false) } - - val currentPosition by rememberUpdatedState(position) - val currentOnFocusPosition by rememberUpdatedState(onFocusPosition) - val currentOnClickPlay by rememberUpdatedState(onClickPlay) - - if (takeFocus) { - LaunchedEffect(homeRows) { - if (!firstFocused && homeRows.isNotEmpty()) { - if (position.row >= 0) { - val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - firstFocused = true - } else { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - firstFocused = true - delay(50) - listState.scrollToItem(it) - } + val listState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + val positionFocusRequester = remember { FocusRequester() } + var focused by remember { mutableStateOf(false) } + LaunchedEffect(homeRows) { + if (!focused) { + homeRows + .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + .takeIf { it >= 0 } + ?.let { + positionFocusRequester.tryRequestFocus() + delay(50) + listState.animateScrollToItem(position.row) + focused = true } - } } } - LaunchedEffect(onUpdateBackdrop, focusedItem) { - focusedItem?.let { onUpdateBackdrop.invoke(it) } + LaunchedEffect(position) { + listState.animateScrollToItem(position.row) + } + var backdropImageUrl by remember { mutableStateOf<String?>(null) } + LaunchedEffect(focusedItem) { + backdropImageUrl = null + delay(150) + backdropImageUrl = focusedItem?.backdropImageUrl } Box(modifier = modifier) { - Column( + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(backdropImageUrl) + .transitionFactory(CrossFadeFactory(250.milliseconds)) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, modifier = Modifier - .focusProperties { - onEnter = { - rowFocusRequesters.getOrNull(currentPosition.row)?.tryRequestFocus() - } - }.fillMaxSize(), - ) { - headerComposable.invoke(focusedItem) + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .alpha(.75f) + .align(Alignment.TopEnd) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = size.height * .33f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + startX = 0f, + endX = size.width * .5f, + ), + ) + }, + ) - val density = LocalDensity.current - val spaceAbovePx = - remember(density) { - with(density) { - // The size of the row titles & spacing - 50.dp.toPx() - } - } - val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current - CompositionLocalProvider( - LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), + Column(modifier = Modifier.fillMaxSize()) { + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .fillMaxWidth(.6f) + .fillMaxHeight(.33f) + .padding(16.dp), + ) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 0.dp, + bottom = Cards.height2x3, + ), + modifier = Modifier.focusRestorer(), ) { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), - ) { - itemsIndexed(homeRows) { rowIndex, row -> - val rowModifier = - Modifier - .animateItem(placementSpec = null) - .padding(bottom = 8.dp) - CompositionLocalProvider( - LocalBringIntoViewSpec provides defaultBringIntoViewSpec, - ) { - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - FocusableItemRow( - title = r.title.getString(), - subtitle = stringResource(R.string.loading), - modifier = rowModifier, - ) - } + itemsIndexed(homeRows) { rowIndex, row -> + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } - is HomeRowLoadingState.Error -> { - FocusableItemRow( - title = r.title.getString(), - subtitle = r.localizedMessage, - isError = true, - modifier = rowModifier, - ) - } + is HomeRowLoadingState.Error -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = r.localizedMessage, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - val viewOptions = row.viewOptions - ItemRow( - title = row.title.getString(), - items = row.items, - onClickItem = - remember(rowIndex, onClickItem) { - { index, item -> - onClickItem.invoke( - RowColumn( - rowIndex, - index, - ), - item, - ) - } - }, - onLongClickItem = - remember(rowIndex, onLongClickItem) { - { index, item -> - onLongClickItem.invoke( - RowColumn(rowIndex, index), - item, - ) - } - }, + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + ItemRow( + title = row.title, + items = row.items, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, + cardOnFocus = { isFocused, index -> + if (isFocused) { + focusedItem = row.items.getOrNull(index) + position = RowColumn(rowIndex, index) + } + }, + onLongClickItem = { index, item -> + onLongClickItem.invoke(RowColumn(rowIndex, index), item) + }, + modifier = + Modifier + .fillMaxWidth() + .animateItem(), + cardContent = { index, item, cardModifier, onClick, onLongClick -> + BannerCard( + name = item?.data?.seriesName ?: item?.name, + imageUrl = item?.imageUrl, + aspectRatio = AspectRatios.TALL, + cornerText = + item?.data?.indexNumber?.let { "E$it" } + ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, modifier = - rowModifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]), - horizontalPadding = viewOptions.spacing.dp, - cardContent = { index, item, cardModifier, onClick, onLongClick -> - val onFocus = - remember(rowIndex, index) { - { isFocused: Boolean -> - if (isFocused) { - currentOnFocusPosition( - RowColumn( - rowIndex, - index, - ), - ) - } + cardModifier + .ifElse( + focusedItem == item, + Modifier.focusRequester(focusRequester), + ).ifElse( + RowColumn(rowIndex, index) == position, + Modifier.focusRequester( + positionFocusRequester, + ), + ).onFocusChanged { + if (it.isFocused) { + val nonEmptyRowBefore = + homeRows + .subList(0, rowIndex) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition?.invoke( + RowColumn( + rowIndex - nonEmptyRowBefore, + index, + ), + ) } - } - val onKey = - remember(item) { - { event: KeyEvent -> - if (isPlayKeyUp(event) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - currentOnClickPlay( - currentPosition, - item, - ) - true - } else { - false - } - } - } - HomePageCardContent( - index = index, - item = item, - onClick = onClick, - onLongClick = onLongClick, - viewOptions = viewOptions, - modifier = - cardModifier - .onFocusChanged { onFocus(it.isFocused) } - .onKeyEvent { onKey(it) }, - ) - }, - showViewMore = showViewMore && row.showViewMore, - viewMoreCardContent = { mod -> - HomePageViewMoreCard( - isEpisode = row.items.last()?.type == BaseItemKind.EPISODE, - onClick = { - onClickViewMore.invoke( - RowColumn( - rowIndex, - r.items.size, - ), - r, - ) }, - onLongClick = {}, - viewOptions = viewOptions, - modifier = - mod.onFocusChanged { - if (it.isFocused) { - currentOnFocusPosition.invoke( - RowColumn( - rowIndex, - r.items.size, - ), - ) - } - }, - ) - }, + interactionSource = null, + cardHeight = Cards.height2x3, ) - } else if (showEmptyRows) { - FocusableItemRow( - title = r.title.getString(), - subtitle = stringResource(R.string.no_results), - modifier = rowModifier, - ) - } - } + }, + ) } } } @@ -523,7 +423,7 @@ fun HomePageContent( when (loadingState) { LoadingState.Pending, LoadingState.Loading, - -> { + -> Box( modifier = Modifier @@ -533,7 +433,6 @@ fun HomePageContent( ) { CircularProgress(Modifier.fillMaxSize()) } - } else -> {} } @@ -543,224 +442,88 @@ fun HomePageContent( @Composable fun HomePageHeader( item: BaseItem?, - showLogo: Boolean, modifier: Modifier = Modifier, ) { - val isEpisode = item?.type == BaseItemKind.EPISODE - val title = - remember(item?.id) { - when (item?.type) { - BaseItemKind.EPISODE, - BaseItemKind.SEASON, - -> item.data.seriesName - - else -> item?.title - } - } - val subtitle = - remember(item?.id) { - when (item?.type) { - BaseItemKind.SEASON, - BaseItemKind.EPISODE, - -> item.name - - else -> null - } - } - HomePageHeader( - title = title, - subtitle = subtitle, - overview = item?.data?.overview, - overviewTwoLines = isEpisode, - quickDetails = item?.ui?.quickDetails, - ratings = rememberSourceRatings(item), - timeRemaining = item?.timeRemainingOrRuntime, - endsAt = item?.data?.endDate, - showLogo = showLogo, - logoImageUrl = rememberLogoUrl(item), - modifier = modifier, - ) -} - -@Composable -fun HomePageHeader( - title: String?, - subtitle: String?, - overview: String?, - overviewTwoLines: Boolean, - quickDetails: QuickDetailsData?, - ratings: SourceRatings?, - timeRemaining: Duration?, - endsAt: DateTime?, - showLogo: Boolean, - logoImageUrl: String?, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + Box( modifier = modifier, ) { - TitleOrLogo( - title = title, - logoImageUrl = logoImageUrl, - showLogo = showLogo, - modifier = Modifier.fillMaxWidth(.75f), - ) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth(.6f), + modifier = Modifier.fillMaxSize(), ) { - if (subtitle != null) { - EpisodeName(subtitle) - } - QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) - val overviewModifier = - Modifier - .padding(0.dp) - .height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp) - .width(400.dp) - if (overview.isNotNullOrBlank()) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (overviewTwoLines) 2 else 3, - overflow = TextOverflow.Ellipsis, - modifier = overviewModifier, - ) - } else { - Spacer(overviewModifier) + item?.let { + val dto = item.data + val isEpisode = item.type == BaseItemKind.EPISODE + val title = if (isEpisode) dto.seriesName ?: item.name else item.name + val subtitle = if (isEpisode) dto.name else null + val overview = dto.overview + val details = + buildList { + if (isEpisode) { + val se = dto.seasonEpisode + if (se != null) { + add(se) + } else if (dto.parentIndexNumber != null) { + // Maybe a daily episode, so just show season, the date is added below + add("S${dto.parentIndexNumber}") + } + } + if (isEpisode) { + dto.premiereDate?.let { add(formatDateTime(it)) } + } else { + dto.productionYear?.let { add(it.toString()) } + } + dto.runTimeTicks?.ticks?.roundMinutes?.let { + add(it.toString()) + } + dto.timeRemaining?.roundMinutes?.let { + add("$it left") + } + dto.officialRating?.let(::add) + } + title?.let { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + subtitle?.let { + Text( + text = subtitle, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (details.isNotEmpty()) { + DotSeparatedRow( + texts = details, + rating = item.data.communityRating, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier, + ) + } + val overviewModifier = + Modifier + .padding(0.dp) + .height(48.dp + if (!isEpisode) 12.dp else 0.dp) + if (overview.isNotNullOrBlank()) { + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (isEpisode) 2 else 3, + overflow = TextOverflow.Ellipsis, + modifier = overviewModifier, + ) + } else { + Spacer(overviewModifier) + } } } } } - -@Composable -fun HomePageCardContent( - index: Int, - item: BaseItem?, - onClick: () -> Unit, - onLongClick: () -> Unit, - viewOptions: HomeRowViewOptions, - modifier: Modifier, -) { - when (item?.type) { - BaseItemKind.GENRE -> { - GenreCard( - genreId = item.id, - name = item.name, - imageUrl = item.imageUrlOverride, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier.height(viewOptions.heightDp.dp), - ) - } - - BaseItemKind.STUDIO -> { - StudioCard( - studioId = item.id, - name = item.name, - imageUrl = item.imageUrlOverride, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier.height(viewOptions.heightDp.dp), - ) - } - - else -> { - val imageType = - remember(item, viewOptions) { - if (item?.type == BaseItemKind.EPISODE) { - viewOptions.episodeImageType.imageType - } else { - viewOptions.imageType.imageType - } - } - val ratio = - remember(item, viewOptions) { - if (item?.type == BaseItemKind.EPISODE) { - viewOptions.episodeAspectRatio.ratio - } else { - viewOptions.aspectRatio.ratio - } - } - val scale = - remember(item, viewOptions) { - if (item?.type == BaseItemKind.EPISODE) { - viewOptions.episodeContentScale.scale - } else { - viewOptions.contentScale.scale - } - } - if (viewOptions.showTitles) { - BannerCardWithTitle( - title = item?.title, - subtitle = item?.subtitle, - item = item, - aspectRatio = ratio, - imageType = imageType, - imageContentScale = scale, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - cardHeight = viewOptions.heightDp.dp, - useSeriesForPrimary = viewOptions.useSeries, - ) - } else { - BannerCard( - name = item?.data?.seriesName ?: item?.name, - item = item, - aspectRatio = ratio, - imageType = imageType, - imageContentScale = scale, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = null, - cardHeight = viewOptions.heightDp.dp, - useSeriesForPrimary = viewOptions.useSeries, - ) - } - } - } -} - -@Composable -fun HomePageViewMoreCard( - isEpisode: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - viewOptions: HomeRowViewOptions, - modifier: Modifier, -) { - val aspectRatio = - remember(isEpisode, viewOptions) { - if (isEpisode) { - viewOptions.episodeAspectRatio - } else { - viewOptions.aspectRatio - } - } - ViewMoreCard( - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - aspectRatio = aspectRatio, - size = DpSize(height = viewOptions.heightDp.dp, width = Dp.Unspecified), - showTitle = viewOptions.showTitles, - ) -} 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 d02ba907..bd2b0453 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -1,380 +1,328 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.HomeDataService -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.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.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 -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.util.EmptyStringProvider +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.supportItemKinds 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.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.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.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber +import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class HomeViewModel @Inject constructor( @param:ApplicationContext private val context: Context, + val api: ApiClient, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val serverReportService: ServerReportService, - private val navDrawerService: NavDrawerService, - private val homeSettingsService: HomeSettingsService, - private val homeDataService: HomeDataService, + val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, - private val backdropService: BackdropService, - private val userPreferencesService: UserPreferencesService, - private val mediaManagementService: MediaManagementService, - private val latestNextUpService: LatestNextUpService, ) : ViewModel() { - private val _state = MutableStateFlow(HomeState.EMPTY) - val state: StateFlow<HomeState> = _state + val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) + val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) + val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) + val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) - private var dataLoadingJob: Job? = null + private lateinit var preferences: UserPreferences init { datePlayedService.invalidateAll() - serverRepository.currentUserDtoFlow - .combinePair(homeSettingsService.currentSettings) - .collectLatestIn(viewModelScope) { (userDto, settings) -> - Timber.v( - "Got new userDto & settings, userId=%s, settings?=%s", - userDto?.id, - settings != HomePageResolvedSettings.EMPTY, - ) -// Timber.v("userDto=%s", userDto) - dataLoadingJob?.cancel() - if (userDto == null) { - Timber.d("UserDto is null") - _state.update { HomeState.EMPTY } - return@collectLatestIn - } - if (settings == HomePageResolvedSettings.EMPTY) { - Timber.d("Home settings are empty") - _state.update { HomeState.EMPTY } - return@collectLatestIn - } - if (userDto.id != settings.userId) { - Timber.d("User IDs don't match: %s vs %s", userDto?.id, settings.userId) - _state.update { HomeState.EMPTY } - return@collectLatestIn - } - if (state.value.settings.userId != settings.userId) { - Timber.d("User changed") - _state.update { HomeState.EMPTY } - } - dataLoadingJob = - viewModelScope.launchIO { - loadHomeRows(userDto, settings) - } - } } - fun init() { - viewModelScope.launchIO { + fun init(preferences: UserPreferences): Job { + val reload = loadingState.value != LoadingState.Success + if (reload) { + loadingState.value = LoadingState.Loading + } + refreshState.value = LoadingState.Loading + this.preferences = preferences + val prefs = preferences.appPreferences.homePagePreferences + val limit = prefs.maxItemsPerRow + return viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loadingState, + "Error loading home page", + ), + ) { Timber.d("init HomeViewModel") - serverRepository.currentUserDto?.let { userDto -> - if (dataLoadingJob?.isActive == false) { - val settings = - homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - 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, - ) - } - } else { - Timber.v("Data loading job is active") - } - } - } - } - suspend fun loadHomeRows( - userDto: ServerUserConfig, - 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 + serverRepository.currentUserDto.value?.let { userDto -> + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + // TODO data is fetched all together which may be slow for large servers + val resume = getResume(userDto.id, limit, true) + val nextUp = + getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + ) + val watching = + buildList { + if (prefs.combineContinueNext) { + val items = buildCombined(resume, nextUp) + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = items, + ), + ) } else { - List(settings.rows.size) { - HomeRowLoadingState.Pending(EmptyStringProvider) + if (resume.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = resume, + ), + ) } - }, - ) - } - - val semaphore = Semaphore(4) - - val deferred = - settings.rows - .map { row -> - viewModelScope.async(WholphinDispatchers.IO) { - semaphore.withPermit { - Timber.v("Fetching row: %s", row) - try { - homeDataService.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 (nextUp.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), + items = nextUp, + ), + ) } } } - 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, - ) + val latest = getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + + withContext(Dispatchers.Main) { + this@HomeViewModel.watchingRows.value = watching + if (reload) { + this@HomeViewModel.latestRows.value = pendingLatest } + loadingState.value = LoadingState.Success } - _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)) - } + loadLatest(latest) + refreshState.setValueOnMain(LoadingState.Success) } } } + private suspend fun getResume( + userId: UUID, + limit: Int, + includeEpisodes: Boolean, + ): List<BaseItem> { + val request = + GetResumeItemsRequest( + userId = userId, + fields = SlimItemFields, + limit = limit, + includeItemTypes = + if (includeEpisodes) { + supportItemKinds + } else { + supportItemKinds + .toMutableSet() + .apply { + remove(BaseItemKind.EPISODE) + } + }, + ) + val items = + api.itemsApi + .getResumeItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return items + } + + private suspend fun getNextUp( + userId: UUID, + limit: Int, + enableRewatching: Boolean, + enableResumable: Boolean, + ): List<BaseItem> { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + limit = limit, + enableResumable = enableResumable, + enableUserData = true, + enableRewatching = enableRewatching, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return nextUp + } + + private suspend fun getLatest( + user: UserDto, + limit: Int, + includedIds: List<UUID>, + ): List<LatestData> { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() + val views by api.userViewsApi.getUserViews() + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + // Exclude Live TV because a recording folder view will be used instead + it.collectionType != CollectionType.LIVETV + }.map { view -> + val title = + view.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = view.id, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) + } + + return latestData + } + + private suspend fun loadLatest(latestData: List<LatestData>) { + val rows = + latestData.map { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + latestRows.setValueOnMain(rows) + } + + private suspend fun buildCombined( + resume: List<BaseItem>, + nextUp: List<BaseItem>, + ): List<BaseItem> = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + viewModelScope.async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf<UUID, LocalDateTime?>() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } + fun setWatched( itemId: UUID, played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - withContext(WholphinDispatchers.Main) { - init() + withContext(Dispatchers.Main) { + init(preferences) } } fun setFavorite( itemId: UUID, favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - withContext(WholphinDispatchers.Main) { - init() - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun deleteItem( - position: RowColumn, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - val row = state.value.homeRows.getOrNull(position.row) - if (row is HomeRowLoadingState.Success) { - _state.update { - val newRow = - row.items.toMutableList().apply { - removeAt(position.column) - } - it.copy( - homeRows = - it.homeRows.toMutableList().apply { - set(position.row, row.copy(items = newRow)) - }, - ) - } - } - } - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun removeFromNextUp(item: BaseItem) { - if (item.type == BaseItemKind.EPISODE) { - viewModelScope.launchDefault { - serverRepository.currentUser?.id?.let { userId -> - latestNextUpService.removeFromNextUp(userId, item) - init() - } - } - } else { - Timber.w("Item is not an episode %s", item.id) + withContext(Dispatchers.Main) { + init(preferences) } } } -data class HomeState( - val loadingState: LoadingState, - val refreshState: LoadingState, - val homeRows: List<HomeRowLoadingState>, - val settings: HomePageResolvedSettings, -) { - companion object { - val EMPTY = - HomeState( - LoadingState.Pending, - LoadingState.Pending, - emptyList(), - HomePageResolvedSettings.EMPTY, - ) - } -} +val supportedLatestCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.LIVETV, + ) -/** - * Whether a row is a "is watching" type - */ -private fun isWatchingRow(row: HomeRowConfig) = - row is HomeRowConfig.ContinueWatching || - row is HomeRowConfig.NextUp || - row is HomeRowConfig.ContinueWatchingCombined +data class LatestData( + val title: String, + val request: GetLatestMediaRequest, +) 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 new file mode 100644 index 00000000..2a57fd34 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -0,0 +1,366 @@ +package com.github.damontecres.wholphin.ui.main + +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +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.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.EpisodeCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + val api: ApiClient, + val navigationManager: NavigationManager, + ) : ViewModel() { + val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery) + val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) + val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) + val collections = MutableLiveData<SearchResult>(SearchResult.NoQuery) + + private var currentQuery: String? = null + + fun search(query: String?) { + if (currentQuery == query) { + return + } + currentQuery = query + if (query.isNotNullOrBlank()) { + movies.value = SearchResult.Searching + series.value = SearchResult.Searching + episodes.value = SearchResult.Searching + collections.value = SearchResult.Searching + searchInternal(query, BaseItemKind.MOVIE, movies) + searchInternal(query, BaseItemKind.SERIES, series) + searchInternal(query, BaseItemKind.EPISODE, episodes) + searchInternal(query, BaseItemKind.BOX_SET, collections) + } else { + movies.value = SearchResult.NoQuery + series.value = SearchResult.NoQuery + episodes.value = SearchResult.NoQuery + collections.value = SearchResult.NoQuery + } + } + + private fun searchInternal( + query: String, + type: BaseItemKind, + target: MutableLiveData<SearchResult>, + ) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + try { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = 25, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + pager.init() + withContext(Dispatchers.Main) { + target.value = SearchResult.Success(pager) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") + withContext(Dispatchers.Main) { + target.value = SearchResult.Error(ex) + } + } + } + } + + 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 +} + +private const val MOVIE_ROW = 0 +private const val COLLECTION_ROW = MOVIE_ROW + 1 +private const val SERIES_ROW = COLLECTION_ROW + 1 +private const val EPISODE_ROW = SERIES_ROW + 1 + +@Composable +fun SearchPage( + userPreferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) + val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) + val series by viewModel.series.observeAsState(SearchResult.NoQuery) + val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + + var query by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + + var position by rememberPosition() + + LaunchedEffect(query) { + delay(750L) + viewModel.search(query) + } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + val onClickItem = { index: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } + + LazyColumn( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + SearchEditTextBox( + value = query, + onValueChange = { + query = it + }, + onSearchClick = { + viewModel.search(query) + }, + modifier = + Modifier.ifElse( + position.row < MOVIE_ROW, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + searchResultRow( + title = context.getString(R.string.movies), + result = movies, + rowIndex = MOVIE_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = context.getString(R.string.collections), + result = collections, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = context.getString(R.string.tv_shows), + result = series, + rowIndex = SERIES_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = context.getString(R.string.episodes), + result = episodes, + rowIndex = EPISODE_ROW, + position = position, + focusRequester = focusRequester, + 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) + .ifElse( + position.row == EPISODE_ROW && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, + ) + } +} + +fun LazyListScope.searchResultRow( + title: String, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + 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, + modifier = + mod + .ifElse( + position.row == rowIndex && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, +) { + item { + when (val r = result) { + is SearchResult.Error -> + SearchResultPlaceholder( + title = title, + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> + SearchResultPlaceholder( + title = title, + message = stringResource(R.string.searching), + modifier = modifier, + ) + + is SearchResult.Success -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = title, + message = stringResource(R.string.no_results), + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = r.items, + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + modifier = modifier, + cardContent = cardContent, + ) + } + } + } + } +} + +@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 deleted file mode 100644 index c72d69a8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import androidx.tv.material3.ListItem -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.services.SuggestionsWorker -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.tryRequestFocus -import org.jellyfin.sdk.model.api.CollectionType - -@Composable -fun HomeLibraryRowTypeList( - library: Library, - onClick: (LibraryRowType) -> Unit, - modifier: Modifier, - firstFocus: FocusRequester = remember { FocusRequester() }, -) { - val items = remember(library) { getSupportedRowTypes(library) } - LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - TitleText(stringResource(R.string.add_row_for, library.name)) - HomeSettingsLazyColumn( - modifier = - modifier - .fillMaxHeight() - .focusRestorer(firstFocus), - ) { - itemsIndexed(items) { index, rowType -> - ListItem( - selected = false, - headlineContent = { - Text( - text = stringResource(rowType.stringId), - ) - }, - onClick = { onClick.invoke(rowType) }, - modifier = - Modifier - .fillMaxWidth() - .ifElse(index == 0, Modifier.focusRequester(firstFocus)), - ) - } - } - } -} - -fun getSupportedRowTypes(library: Library): List<LibraryRowType> { - val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null - return when { - library.isRecordingFolder -> { - listOf( - LibraryRowType.RECENTLY_RECORDED, - LibraryRowType.GENRES, - ) - } - - library.collectionType == CollectionType.LIVETV -> { - listOf( - LibraryRowType.TV_CHANNELS, - LibraryRowType.TV_PROGRAMS, - ) - } - - library.collectionType == CollectionType.TVSHOWS -> { - listOf( - LibraryRowType.RECENTLY_ADDED, - LibraryRowType.RECENTLY_RELEASED, - LibraryRowType.SUGGESTIONS, - LibraryRowType.GENRES, - LibraryRowType.STUDIOS, - ) - } - - supportsSuggestions -> { - listOf( - LibraryRowType.RECENTLY_ADDED, - LibraryRowType.RECENTLY_RELEASED, - LibraryRowType.SUGGESTIONS, - LibraryRowType.GENRES, - ) - } - - library.collectionType == CollectionType.BOXSETS -> { - listOf( - LibraryRowType.RECENTLY_ADDED, - LibraryRowType.RECENTLY_RELEASED, - LibraryRowType.GENRES, - LibraryRowType.COLLECTION, - ) - } - - library.collectionType == CollectionType.PLAYLISTS -> { - listOf( - LibraryRowType.RECENTLY_ADDED, - LibraryRowType.RECENTLY_RELEASED, - LibraryRowType.GENRES, - LibraryRowType.PLAYLIST, - ) - } - - else -> { - listOf( - LibraryRowType.RECENTLY_ADDED, - LibraryRowType.RECENTLY_RELEASED, - LibraryRowType.GENRES, - ) - } - } -} - -enum class LibraryRowType( - @param:StringRes val stringId: Int, -) { - RECENTLY_ADDED(R.string.recently_added), - RECENTLY_RELEASED(R.string.recently_released), - SUGGESTIONS(R.string.suggestions), - GENRES(R.string.genres), - STUDIOS(R.string.studios), - TV_CHANNELS(R.string.channels), - 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/HomeRowConfigAction.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt deleted file mode 100644 index 2a44c8f4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -sealed interface HomeRowConfigAction { - data object Combine : HomeRowConfigAction - - data object Split : HomeRowConfigAction -} 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 deleted file mode 100644 index 922ae3cc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.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( - val continueWatching: HomeRowViewOptions, - val movieLibrary: HomeRowViewOptions, - val tvLibrary: HomeRowViewOptions, - val videoLibrary: HomeRowViewOptions, - val photoLibrary: HomeRowViewOptions, - val musicLibrary: HomeRowViewOptions, - val playlist: HomeRowViewOptions, - val liveTv: HomeRowViewOptions, - val genreSize: Int, -) { - fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = - when (collectionType) { - CollectionType.MOVIES -> movieLibrary - - CollectionType.TVSHOWS -> tvLibrary - - CollectionType.MUSICVIDEOS -> videoLibrary - - CollectionType.TRAILERS -> videoLibrary - - CollectionType.HOMEVIDEOS -> videoLibrary - - CollectionType.BOXSETS -> movieLibrary - - CollectionType.PHOTOS -> photoLibrary - - CollectionType.LIVETV -> liveTv - - CollectionType.MUSIC -> musicLibrary - - CollectionType.UNKNOWN, - CollectionType.BOOKS, - CollectionType.PLAYLISTS, - CollectionType.FOLDERS, - -> 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( - continueWatching = HomeRowViewOptions(), - movieLibrary = HomeRowViewOptions(), - tvLibrary = HomeRowViewOptions(), - videoLibrary = - HomeRowViewOptions( - aspectRatio = AspectRatio.WIDE, - ), - photoLibrary = - HomeRowViewOptions( - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.CROP, - ), - musicLibrary = - HomeRowViewOptions( - aspectRatio = AspectRatio.SQUARE, - ), - playlist = - HomeRowViewOptions( - aspectRatio = AspectRatio.SQUARE, - contentScale = PrefContentScale.FIT, - ), - liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = HomeRowViewOptions.genreDefault.heightDp, - ) - } - - val WholphinCompact by lazy { - val height = 148 - val epHeight = 100 - HomeRowPresets( - continueWatching = - HomeRowViewOptions( - heightDp = height, - ), - movieLibrary = - HomeRowViewOptions( - heightDp = height, - ), - tvLibrary = - HomeRowViewOptions( - heightDp = height, - ), - videoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - ), - photoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.CROP, - ), - musicLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - ), - playlist = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - contentScale = PrefContentScale.FIT, - ), - liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = epHeight, - ) - } - - val SeriesThumbs by lazy { - val height = 148 - val epHeight = 100 - HomeRowPresets( - continueWatching = - HomeRowViewOptions( - heightDp = epHeight, - imageType = ViewOptionImageType.THUMB, - aspectRatio = AspectRatio.WIDE, - useSeries = true, - episodeImageType = ViewOptionImageType.THUMB, - episodeAspectRatio = AspectRatio.WIDE, - ), - movieLibrary = - HomeRowViewOptions( - heightDp = height, - ), - tvLibrary = - HomeRowViewOptions( - heightDp = height, - ), - videoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - ), - photoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.CROP, - ), - musicLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - ), - playlist = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - contentScale = PrefContentScale.FIT, - ), - liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = epHeight, - ) - } - - val EpisodeThumbnails by lazy { - val height = 148 - val epHeight = 100 - HomeRowPresets( - continueWatching = - HomeRowViewOptions( - heightDp = epHeight, - imageType = ViewOptionImageType.THUMB, - aspectRatio = AspectRatio.WIDE, - showTitles = true, - useSeries = false, - episodeImageType = ViewOptionImageType.PRIMARY, - episodeAspectRatio = AspectRatio.WIDE, - ), - movieLibrary = - HomeRowViewOptions( - heightDp = height, - ), - tvLibrary = - HomeRowViewOptions( - heightDp = height, - ), - videoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - ), - photoLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.CROP, - ), - musicLibrary = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - ), - playlist = - HomeRowViewOptions( - heightDp = epHeight, - aspectRatio = AspectRatio.SQUARE, - contentScale = PrefContentScale.FIT, - ), - liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = epHeight, - ) - } - } -} - -@Composable -fun HomeRowPresetsContent( - onApply: (HomeRowPresets) -> Unit, - modifier: Modifier = Modifier, -) { - val presets = - listOf( - stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, - stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, - stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, - stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, - ) - - val focusRequesters = remember { List(presets.size) { FocusRequester() } } - LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } - Column(modifier = modifier) { - TitleText(stringResource(R.string.display_presets)) - HomeSettingsLazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .fillMaxHeight() - .focusRestorer(focusRequesters[0]), - ) { - itemsIndexed(presets) { index, (title, preset) -> - HomeSettingsListItem( - selected = false, - headlineText = title, - onClick = { - onApply.invoke(preset) - }, - modifier = Modifier.focusRequester(focusRequesters[index]), - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt deleted file mode 100644 index 48901b0f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ /dev/null @@ -1,464 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import android.view.Gravity -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.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.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.ListItem -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.AppChoicePreference -import com.github.damontecres.wholphin.preferences.AppClickablePreference -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppSliderPreference -import com.github.damontecres.wholphin.preferences.AppSwitchPreference -import com.github.damontecres.wholphin.preferences.PrefContentScale -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.ViewOptionImageType -import com.github.damontecres.wholphin.ui.components.toAnnotatedString -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.data.getStringRes -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.preferences.ClickPreference -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup -import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle -import com.github.damontecres.wholphin.ui.tryRequestFocus -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder - -@Composable -fun HomeRowSettings( - title: String, - config: HomeRowConfig, - preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>, - viewOptions: HomeRowViewOptions, - onViewOptionsChange: (HomeRowViewOptions) -> Unit, - onConfigChange: (HomeRowConfig) -> Unit, - onConfigAction: (HomeRowConfigAction) -> Unit, - onApplyApplyAll: () -> Unit, - modifier: Modifier = Modifier, - defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), -) { - val firstFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - TitleText(title) - HomeSettingsLazyColumn { - preferenceOptions.forEachIndexed { groupIndex, prefGroup -> - if (preferenceOptions.size > 1) { - item { - SubTitleText(stringResource(prefGroup.title)) - } - } - itemsIndexed(prefGroup.preferences) { index, pref -> - pref as AppPreference<HomeRowViewOptions, Any> - val interactionSource = remember { MutableInteractionSource() } - val value = pref.getter.invoke(viewOptions) - ComposablePreference( - preference = pref, - value = value, - onNavigate = {}, - onValueChange = { newValue -> - onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) - }, - interactionSource = interactionSource, - onClickPreference = { pref -> - when (pref) { - Options.ViewOptionsReset -> { - onViewOptionsChange.invoke(defaultViewOptions) - } - - Options.ViewOptionsApplyAll -> { - onApplyApplyAll.invoke() - } - - Options.ViewOptionsUseThumb -> { - onViewOptionsChange.invoke( - viewOptions.copy( - heightDp = Cards.HEIGHT_EPISODE, - spacing = 20, - imageType = ViewOptionImageType.THUMB, - aspectRatio = AspectRatio.WIDE, - contentScale = PrefContentScale.FIT, - episodeImageType = ViewOptionImageType.THUMB, - episodeAspectRatio = AspectRatio.WIDE, - episodeContentScale = PrefContentScale.FIT, - ), - ) - } - } - }, - modifier = - Modifier - .ifElse( - groupIndex == 0 && index == 0, - Modifier.focusRequester(firstFocus), - ), - ) - } - } - addAdditionalConfig( - config = config, - onConfigChange = onConfigChange, - onConfigAction = onConfigAction, - ) - } - } -} - -private fun LazyListScope.addAdditionalConfig( - config: HomeRowConfig, - onConfigChange: (HomeRowConfig) -> Unit, - onConfigAction: (HomeRowConfigAction) -> Unit, -) { - when (config) { - is HomeRowConfig.ContinueWatchingCombined -> { - item { - ClickPreference( - title = stringResource(R.string.split_into_separate_rows), - onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) }, - ) - } - } - - is HomeRowConfig.ContinueWatching, - is HomeRowConfig.NextUp, - -> { - item { - ClickPreference( - title = stringResource(R.string.combine_continue_next), - onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) }, - ) - } - } - - is HomeRowConfig.ByParent -> { - item { - val sortAndDirection = - remember(config) { - config.sort ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - } - var showDialog by remember { mutableStateOf(false) } - ListItem( - selected = false, - onClick = { showDialog = true }, - headlineContent = { - PreferenceTitle(stringResource(R.string.sort)) - }, - supportingContent = { - Text( - text = sortAndDirection.toAnnotatedString(), - ) - }, - ) - if (showDialog) { - BasicDialog( - onDismissRequest = { showDialog = false }, - properties = DialogProperties(usePlatformDefaultWidth = false), - elevation = 5.dp, - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.START) - } - ByParentSortDialogContent( - current = sortAndDirection, - onClick = { - val newSort = - if (config.sort?.sort == it) { - config.sort.flip() - } else if (it == ItemSortBy.DEFAULT) { - null - } else { - SortAndDirection(it, SortOrder.ASCENDING) - } - onConfigChange.invoke(config.copy(sort = newSort)) - showDialog = false - }, - modifier = Modifier.width(320.dp), - ) - } - } - } - } - - else -> {} - } -} - -@Composable -fun ByParentSortDialogContent( - current: SortAndDirection, - onClick: (ItemSortBy) -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = modifier, - ) { - items(BoxSetSortOptions) { sort -> - ListItem( - selected = sort == current.sort, - onClick = { onClick.invoke(sort) }, - leadingContent = { - Text( - fontFamily = FontAwesome, - text = - if (sort == current.sort) { - stringResource( - if (current.direction == SortOrder.ASCENDING) { - R.string.fa_caret_up - } else { - R.string.fa_caret_down - }, - ) - } else { - "" - }, - ) - }, - headlineContent = { - Text( - text = stringResource(getStringRes(sort)), - ) - }, - ) - } - } -} - -internal object Options { - val ViewOptionsCardHeight = - AppSliderPreference<HomeRowViewOptions>( - title = R.string.height, - defaultValue = Cards.HEIGHT_2X3_DP.toLong(), - min = 64L, - max = Cards.HEIGHT_2X3_DP + 64L, - interval = 4, - getter = { it.heightDp.toLong() }, - setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) }, - ) - val ViewOptionsSpacing = - AppSliderPreference<HomeRowViewOptions>( - title = R.string.spacing, - defaultValue = 16, - min = 0, - max = 32, - interval = 2, - getter = { it.spacing.toLong() }, - setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, - ) - - val ViewOptionsContentScale = - AppChoicePreference<HomeRowViewOptions, PrefContentScale>( - title = R.string.global_content_scale, - defaultValue = PrefContentScale.FIT, - displayValues = R.array.content_scale, - getter = { it.contentScale }, - setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, - indexToValue = { PrefContentScale.forNumber(it) }, - valueToIndex = { it.number }, - ) - - val ViewOptionsAspectRatio = - AppChoicePreference<HomeRowViewOptions, AspectRatio>( - title = R.string.aspect_ratio, - defaultValue = AspectRatio.TALL, - displayValues = R.array.aspect_ratios, - getter = { it.aspectRatio }, - setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, - indexToValue = { AspectRatio.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsShowTitles = - AppSwitchPreference<HomeRowViewOptions>( - title = R.string.show_titles, - defaultValue = true, - getter = { it.showTitles }, - setter = { vo, value -> vo.copy(showTitles = value) }, - ) - - val ViewOptionsUseSeries = - AppSwitchPreference<HomeRowViewOptions>( - title = R.string.use_series, - defaultValue = true, - getter = { it.useSeries }, - setter = { vo, value -> vo.copy(useSeries = value) }, - ) - - val ViewOptionsImageType = - AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( - title = R.string.image_type, - defaultValue = ViewOptionImageType.PRIMARY, - displayValues = R.array.image_types, - getter = { it.imageType }, - setter = { viewOptions, value -> - val aspectRatio = - when (value) { - ViewOptionImageType.PRIMARY -> AspectRatio.TALL - ViewOptionImageType.THUMB -> AspectRatio.WIDE - } - viewOptions.copy(imageType = value, aspectRatio = aspectRatio) - }, - indexToValue = { ViewOptionImageType.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsApplyAll = - AppClickablePreference<HomeRowViewOptions>( - title = R.string.apply_all_rows, - ) - - val ViewOptionsReset = - AppClickablePreference<HomeRowViewOptions>( - title = R.string.reset, - ) - - val ViewOptionsUseThumb = - AppClickablePreference<HomeRowViewOptions>( - title = R.string.use_thumb_images, - ) - - val ViewOptionsEpisodeContentScale = - AppChoicePreference<HomeRowViewOptions, PrefContentScale>( - title = R.string.global_content_scale, - defaultValue = PrefContentScale.FIT, - displayValues = R.array.content_scale, - getter = { it.episodeContentScale }, - setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, - indexToValue = { PrefContentScale.forNumber(it) }, - valueToIndex = { it.number }, - ) - - val ViewOptionsEpisodeAspectRatio = - AppChoicePreference<HomeRowViewOptions, AspectRatio>( - title = R.string.aspect_ratio, - defaultValue = AspectRatio.TALL, - displayValues = R.array.aspect_ratios, - getter = { it.episodeAspectRatio }, - setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) }, - indexToValue = { AspectRatio.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val ViewOptionsEpisodeImageType = - AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( - title = R.string.image_type, - defaultValue = ViewOptionImageType.PRIMARY, - displayValues = R.array.image_types, - getter = { it.episodeImageType }, - setter = { viewOptions, value -> - val aspectRatio = - when (value) { - ViewOptionImageType.PRIMARY -> AspectRatio.TALL - ViewOptionImageType.THUMB -> AspectRatio.WIDE - } - viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio) - }, - indexToValue = { ViewOptionImageType.entries[it] }, - valueToIndex = { it.ordinal }, - ) - - val OPTIONS = - listOf( - PreferenceGroup( - title = R.string.general, - preferences = - listOf( - ViewOptionsCardHeight, - ViewOptionsSpacing, - ViewOptionsShowTitles, - ViewOptionsImageType, - ViewOptionsAspectRatio, - ViewOptionsContentScale, - ViewOptionsUseSeries, - ), - ), - PreferenceGroup( - title = R.string.more, - preferences = - listOf( -// ViewOptionsApplyAll, - ViewOptionsUseThumb, - ViewOptionsReset, - ), - ), - ) - - val OPTIONS_EPISODES = - listOf( - PreferenceGroup( - title = R.string.general, - preferences = - listOf( - ViewOptionsCardHeight, - ViewOptionsSpacing, - ViewOptionsShowTitles, - ViewOptionsImageType, - ViewOptionsAspectRatio, - ViewOptionsContentScale, - ), - ), - PreferenceGroup( - title = R.string.for_episodes, - preferences = - listOf( - ViewOptionsUseSeries, - ViewOptionsEpisodeImageType, - ViewOptionsEpisodeAspectRatio, - ViewOptionsEpisodeContentScale, - ), - ), - PreferenceGroup( - title = R.string.more, - preferences = - listOf( - ViewOptionsUseThumb, - ViewOptionsReset, - ), - ), - ) - - val GENRE_OPTIONS = - listOf( - PreferenceGroup( - title = R.string.general, - preferences = - listOf( - ViewOptionsCardHeight, - ViewOptionsSpacing, - ViewOptionsReset, - ), - ), - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt deleted file mode 100644 index 439b2505..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.ifElse - -@Composable -fun HomeSettingsAddRow( - libraries: List<Library>, - showDiscover: Boolean, - onClick: (Library) -> Unit, - onClickMeta: (MetaRowType) -> Unit, - modifier: Modifier, - firstFocus: FocusRequester = remember { FocusRequester() }, -) { -// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - TitleText(stringResource(R.string.add_row)) - HomeSettingsLazyColumn( - modifier = - modifier - .fillMaxHeight() - .focusRestorer(firstFocus), - ) { - itemsIndexed( - listOf( - MetaRowType.CONTINUE_WATCHING, - MetaRowType.NEXT_UP, - MetaRowType.COMBINED_CONTINUE_WATCHING, - ), - ) { index, type -> - HomeSettingsListItem( - selected = false, - headlineText = stringResource(type.stringId), - onClick = { onClickMeta.invoke(type) }, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), - ) - } - item { - TitleText(stringResource(R.string.library)) - HorizontalDivider() - } - itemsIndexed(libraries) { index, library -> - HomeSettingsListItem( - selected = false, - headlineText = library.name, - onClick = { onClick.invoke(library) }, - modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)), - ) - } - item { - TitleText(stringResource(R.string.more)) - HorizontalDivider() - } - itemsIndexed( - listOf( - MetaRowType.FAVORITES, - MetaRowType.COLLECTION, - MetaRowType.PLAYLIST, - ), - ) { index, type -> - HomeSettingsListItem( - selected = false, - headlineText = stringResource(type.stringId), - onClick = { onClickMeta.invoke(type) }, - modifier = Modifier, - ) - } - if (showDiscover) { - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(MetaRowType.DISCOVER.stringId), - onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) }, - modifier = Modifier, - ) - } - } - } - } -} - -enum class MetaRowType( - @param:StringRes val stringId: Int, -) { - CONTINUE_WATCHING(R.string.continue_watching), - NEXT_UP(R.string.next_up), - COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), - FAVORITES(R.string.favorites), - DISCOVER(R.string.discover), - COLLECTION(R.string.collection), - PLAYLIST(R.string.playlist), -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt deleted file mode 100644 index ae958a93..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.navigation3.runtime.NavKey -import kotlinx.serialization.Serializable - -/** - * Tracking the pages for selecting and configuring rows - */ -@Serializable -sealed interface HomeSettingsDestination : NavKey { - @Serializable - data object RowList : HomeSettingsDestination - - @Serializable - data object AddRow : HomeSettingsDestination - - @Serializable - data class ChooseRowType( - val library: Library, - ) : HomeSettingsDestination - - @Serializable - data class RowSettings( - val rowId: Int, - ) : HomeSettingsDestination - - @Serializable - data object ChooseFavorite : HomeSettingsDestination - - @Serializable - data object ChooseDiscover : HomeSettingsDestination - - @Serializable - data object GlobalSettings : HomeSettingsDestination - - @Serializable - data object SourceSettings : HomeSettingsDestination - - @Serializable - data object Presets : HomeSettingsDestination -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt deleted file mode 100644 index 96c7583c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import 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() }, -) { - LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - TitleText( - stringResource(R.string.add_row_for, stringResource(R.string.favorites)), - ) - HomeSettingsLazyColumn( - modifier = - modifier - .fillMaxHeight() - .focusRestorer(firstFocus), - ) { - itemsIndexed(favoriteTypeOptions) { index, type -> - HomeSettingsListItem( - selected = false, - headlineText = stringResource(formatTypeName(type)), - onClick = { onClick.invoke(type) }, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), - ) - } - } - } -} 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 deleted file mode 100644 index 74c546b5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.tryRequestFocus - -@Composable -fun HomeSettingsGlobal( - preferences: AppPreferences, - onPreferenceChange: (AppPreferences) -> Unit, - onClickResize: (Int) -> Unit, - onClickViewNextUp: () -> Unit, - modifier: Modifier = Modifier, -) { - val firstFocus: FocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - Text( - text = stringResource(R.string.settings), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - HorizontalDivider() - HomeSettingsLazyColumn( - modifier = - modifier - .fillMaxHeight() - .focusRestorer(firstFocus), - ) { - item { - ComposablePreference( - preference = AppPreference.HomePageItems, - value = AppPreference.HomePageItems.getter.invoke(preferences), - onValueChange = { - val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it) - onPreferenceChange.invoke(newPrefs) - }, - onNavigate = {}, - modifier = Modifier.focusRequester(firstFocus), - ) - } - item { - ComposablePreference( - preference = AppPreference.RewatchNextUp, - value = AppPreference.RewatchNextUp.getter.invoke(preferences), - onValueChange = { - val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it) - onPreferenceChange.invoke(newPrefs) - }, - onNavigate = {}, - modifier = Modifier, - ) - } - item { - ComposablePreference( - preference = AppPreference.MaxDaysNextUp, - value = AppPreference.MaxDaysNextUp.getter.invoke(preferences), - onValueChange = { - val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it) - onPreferenceChange.invoke(newPrefs) - }, - onNavigate = {}, - modifier = Modifier, - ) - } - item { - ComposablePreference( - preference = AppPreference.HomeClickToPlay, - value = AppPreference.HomeClickToPlay.getter.invoke(preferences), - onValueChange = { - val newPrefs = AppPreference.HomeClickToPlay.setter.invoke(preferences, it) - onPreferenceChange.invoke(newPrefs) - }, - onNavigate = {}, - modifier = Modifier, - ) - } - item { HorizontalDivider() } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.increase_all_cards_size), - leadingContent = { - Icon( - imageVector = Icons.Default.KeyboardArrowUp, - contentDescription = null, - ) - }, - onClick = { onClickResize.invoke(1) }, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.decrease_all_cards_size), - leadingContent = { - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = null, - ) - }, - onClick = { onClickResize.invoke(-1) }, - modifier = Modifier, - ) - } - item { HorizontalDivider() } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.view_removed_next_up), - leadingContent = { - Text( - text = stringResource(R.string.fa_eye), - fontFamily = FontAwesome, - ) - }, - onClick = onClickViewNextUp, - modifier = Modifier, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt deleted file mode 100644 index ec35cdb9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt +++ /dev/null @@ -1,83 +0,0 @@ -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 -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ListItem -import androidx.tv.material3.ListItemBorder -import androidx.tv.material3.ListItemColors -import androidx.tv.material3.ListItemDefaults -import androidx.tv.material3.ListItemGlow -import androidx.tv.material3.ListItemScale -import androidx.tv.material3.ListItemShape -import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle - -@Composable -@NonRestartableComposable -fun HomeSettingsListItem( - selected: Boolean, - onClick: () -> Unit, - headlineText: String, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onLongClick: (() -> Unit)? = null, - overlineContent: (@Composable () -> Unit)? = null, - supportingContent: (@Composable () -> Unit)? = null, - leadingContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null, - tonalElevation: Dp = 3.dp, - shape: ListItemShape = ListItemDefaults.shape(), - colors: ListItemColors = ListItemDefaults.colors(), - scale: ListItemScale = ListItemDefaults.scale(), - border: ListItemBorder = ListItemDefaults.border(), - glow: ListItemGlow = ListItemDefaults.glow(), - interactionSource: MutableInteractionSource? = null, -) = ListItem( - selected = selected, - onClick = onClick, - headlineContent = { - PreferenceTitle(headlineText) - }, - modifier = modifier, - enabled = enabled, - onLongClick = onLongClick, - overlineContent = overlineContent, - supportingContent = supportingContent, - leadingContent = leadingContent, - trailingContent = trailingContent, - tonalElevation = tonalElevation, - shape = shape, - colors = colors, - scale = scale, - border = border, - glow = glow, - interactionSource = interactionSource, -) - -@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 deleted file mode 100644 index 0bd6b7db..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ /dev/null @@ -1,429 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.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.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 -import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber - -val settingsWidth = 360.dp - -@Composable -fun HomeSettingsPage( - preferences: UserPreferences, - modifier: Modifier, - viewModel: HomeSettingsViewModel = hiltViewModel(), -) { - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) - var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) } - var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) } - var showRemovedNextUpDialog by remember { mutableStateOf(false) } - var showSaveDialog by remember { mutableStateOf(false) } - - val state by viewModel.state.collectAsState() - val serverPluginActive by viewModel.serverPluginActive.collectAsState() - var position by rememberPosition(0, 0) - // TODO discover rows - val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) - val settingsHaveChanged by viewModel.settingsChanged.collectAsState() - - // Adds a row, waits until its done loading, then scrolls to the new row - fun addRow( - scrollToBottom: Boolean = true, - func: () -> Job, - ) { - scope.launch(ExceptionHandler(autoToast = true)) { - while (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) - } - func.invoke().join() - if (scrollToBottom) { - listState.animateScrollToItem(state.rows.lastIndex) - } - } - } - - BackHandler(settingsHaveChanged) { - showSaveDialog = true - } - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Box( - modifier = - Modifier - .width(settingsWidth) - .fillMaxHeight() - .background(color = MaterialTheme.colorScheme.surface), - ) { - NavDisplay( - backStack = backStack, -// onBack = { navigationManager.goBack() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), - entryProvider = { key -> - val dest = key as HomeSettingsDestination - NavEntry(dest, contentKey = key.toString()) { - val destModifier = - Modifier - .fillMaxSize() - .padding(8.dp) - when (dest) { - HomeSettingsDestination.RowList -> { - HomeSettingsRowList( - state = state, - onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) }, - onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) }, - onClickPresets = { backStack.add(HomeSettingsDestination.Presets) }, - onClickMove = viewModel::moveRow, - onClickDelete = viewModel::deleteRow, - onClick = { index, row -> - backStack.add(RowSettings(row.id)) - scope.launch(ExceptionHandler()) { - Timber.v("Scroll to $index") - listState.animateScrollToItem(index) - // Update backdrop to first item in the row - (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success) - ?.items - ?.firstOrNull() - ?.let { - viewModel.updateBackdrop(it) - } - position = RowColumn(index, 0) - } - }, - onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, - modifier = destModifier, - ) - } - - is HomeSettingsDestination.AddRow -> { - HomeSettingsAddRow( - libraries = state.libraries, - showDiscover = discoverEnabled, - onClick = { backStack.add(ChooseRowType(it)) }, - onClickMeta = { - when (it) { - MetaRowType.CONTINUE_WATCHING, - MetaRowType.NEXT_UP, - MetaRowType.COMBINED_CONTINUE_WATCHING, - -> { - addRow { viewModel.addRow(it) } - } - - MetaRowType.FAVORITES -> { - backStack.add(HomeSettingsDestination.ChooseFavorite) - } - - MetaRowType.DISCOVER -> { - backStack.add(HomeSettingsDestination.ChooseDiscover) - } - - MetaRowType.COLLECTION -> { - searchForDialog = BaseItemKind.BOX_SET - } - - MetaRowType.PLAYLIST -> { - searchForDialog = BaseItemKind.PLAYLIST - } - } - }, - modifier = destModifier, - ) - } - - is ChooseRowType -> { - HomeLibraryRowTypeList( - library = dest.library, - onClick = { type -> - when (type) { - LibraryRowType.COLLECTION -> { - searchForDialog = BaseItemKind.BOX_SET - } - - LibraryRowType.PLAYLIST -> { - searchForDialog = BaseItemKind.PLAYLIST - } - - else -> { - addRow { viewModel.addRow(dest.library, type) } - } - } - }, - modifier = destModifier, - ) - } - - is RowSettings -> { - val row = - state.rows - .first { it.id == dest.rowId } - val preferenceOptions = - remember(row.config) { - when (row.config) { - is HomeRowConfig.ContinueWatching, - is HomeRowConfig.ContinueWatchingCombined, - -> Options.OPTIONS_EPISODES - - is HomeRowConfig.Genres -> Options.GENRE_OPTIONS - - else -> Options.OPTIONS - } - } - val defaultViewOptions = - remember(row.config) { - when (row.config) { - is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault - else -> HomeRowViewOptions() - } - } - HomeRowSettings( - title = row.title.getString(), - preferenceOptions = preferenceOptions, - viewOptions = row.config.viewOptions, - defaultViewOptions = defaultViewOptions, - onViewOptionsChange = { - viewModel.updateViewOptions(dest.rowId, it) - }, - onApplyApplyAll = { - viewModel.updateViewOptionsForAll(row.config.viewOptions) - }, - modifier = destModifier, - config = row.config, - onConfigChange = { - viewModel.onConfigChange(row, it) - }, - onConfigAction = { - viewModel.onConfigAction(row, it) - when (it) { - HomeRowConfigAction.Combine, - HomeRowConfigAction.Split, - -> { - backStack.removeAt( - backStack.lastIndex, - ) - } - } - }, - ) - } - - HomeSettingsDestination.ChooseDiscover -> { - TODO() - } - - HomeSettingsDestination.ChooseFavorite -> { - val favoriteTypeOptions = - remember { state.libraries.possibleFavoriteTypes(true) } - HomeSettingsFavoriteList( - favoriteTypeOptions = favoriteTypeOptions, - onClick = { type -> - addRow { viewModel.addFavoriteRow(type) } - }, - modifier = destModifier, - ) - } - - HomeSettingsDestination.GlobalSettings -> { - val preferences by - viewModel.preferencesDataStore.data.collectAsState( - AppPreferences.getDefaultInstance(), - ) - - HomeSettingsGlobal( - preferences = preferences, - onPreferenceChange = { newPrefs -> - scope.launchIO { - viewModel.preferencesDataStore.updateData { newPrefs } - } - }, - onClickResize = { viewModel.resizeCards(it) }, - onClickViewNextUp = { - showRemovedNextUpDialog = true - }, - modifier = destModifier, - ) - } - - HomeSettingsDestination.SourceSettings -> { - HomeSettingsSourcePage( - source = state.source, - onClickSave = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_server_settings) { - viewModel.saveToRemote() - } - }, - onClickLoad = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_local_settings) { - viewModel.loadFromRemote() - } - }, - serverPluginActive = serverPluginActive, - onClickLoadPlugin = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_local_settings) { - viewModel.loadFromRemotePlugin() - } - }, - onClickLoadWeb = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_local_settings) { - viewModel.loadFromRemoteWeb() - } - }, - onClickReset = { - showConfirmDialog = - ShowConfirm(R.string.overwrite_local_settings) { - addRow(false) { viewModel.resetToDefault() } - } - }, - modifier = destModifier, - ) - } - - HomeSettingsDestination.Presets -> { - HomeRowPresetsContent( - onApply = viewModel::applyPreset, - modifier = destModifier, - ) - } - } - } - }, - ) - } - HomePageContent( - loadingState = state.loading, - homeRows = state.rowData, - position = position, - onFocusPosition = { position = it }, - onClickItem = { _, _ -> }, - onLongClickItem = { _, _ -> }, - onClickPlay = { _, _ -> }, - showClock = false, - onUpdateBackdrop = viewModel::updateBackdrop, - listState = listState, - takeFocus = false, - showEmptyRows = true, - showLogo = preferences.appPreferences.interfacePreferences.showLogos, - showViewMore = false, - modifier = - Modifier - .fillMaxHeight() - .weight(1f), - ) - } - showConfirmDialog?.let { (body, onConfirm) -> - ConfirmDialog( - title = stringResource(R.string.confirm), - body = stringResource(body), - onCancel = { showConfirmDialog = null }, - onConfirm = { - onConfirm.invoke() - showConfirmDialog = null - }, - ) - } - searchForDialog?.let { searchType -> - SearchForDialog( - onDismissRequest = { searchForDialog = null }, - searchType = searchType, - onClick = { - searchForDialog = null - addRow { viewModel.addRow(searchType, it) } - }, - ) - } - if (showRemovedNextUpDialog) { - BasicDialog( - onDismissRequest = { showRemovedNextUpDialog = false }, - ) { - RemovedNextUpContent( - modifier = Modifier.padding(16.dp), - ) - } - } - if (showSaveDialog) { - SaveHomeSettingsDialog( - onDismissRequest = { showSaveDialog = false }, - onSaveLocal = { - scope.launch { - viewModel.saveToLocal().join() - viewModel.navigationManager.goBack() - } - }, - onSaveServer = { - scope.launch { - viewModel.saveToRemote().join() - viewModel.navigationManager.goBack() - } - }, - onDiscard = { - scope.launch { - viewModel.discardChanges().join() - viewModel.navigationManager.goBack() - } - }, - ) - } -} - -data class ShowConfirm( - @param:StringRes val body: Int, - val onConfirm: () -> Unit, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt deleted file mode 100644 index 72289463..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ /dev/null @@ -1,311 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.services.HomeRowConfigDisplay -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlinx.coroutines.launch - -enum class MoveDirection { - UP, - DOWN, -} - -private const val POS_ADD_ROW = 0 -private const val POS_SOURCE = POS_ADD_ROW + 1 -private const val POS_SETTINGS = POS_SOURCE + 1 -private const val POS_PRESETS = POS_SETTINGS + 1 - -@Composable -fun HomeSettingsRowList( - state: HomePageSettingsState, - onClick: (Int, HomeRowConfigDisplay) -> Unit, - onClickAdd: () -> Unit, - onClickSettings: () -> Unit, - onClickPresets: () -> Unit, - onClickMove: (MoveDirection, Int) -> Unit, - onClickDelete: (Int) -> Unit, - onClickSourceSettings: () -> Unit, - modifier: Modifier, -) { - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - val listState = rememberLazyListState() - - val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider - val focusRequesters = - remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } - - var position by rememberInt(0) - - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } - Column(modifier = modifier) { - TitleText(stringResource(R.string.customize_home)) - HomeSettingsLazyColumn( - state = listState, - modifier = - modifier - .fillMaxHeight() - .focusRestorer(focusRequesters[POS_ADD_ROW]), - ) { - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.add_row), - leadingContent = { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - ) - }, - onClick = { - position = POS_ADD_ROW - onClickAdd.invoke() - }, - modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]), - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = "Source", - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - supportingContent = { - Text(text = stringResource(state.source.stringResId)) - }, - onClick = { - position = POS_SOURCE - onClickSourceSettings.invoke() - }, - modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]), - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.settings), - leadingContent = { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - ) - }, - onClick = { - position = POS_SETTINGS - onClickSettings.invoke() - }, - modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.display_presets), - supportingContent = { - Text( - text = stringResource(R.string.display_presets_description), - ) - }, - leadingContent = { - Text( - text = stringResource(R.string.fa_sliders), - fontFamily = FontAwesome, - ) - }, - onClick = { - position = POS_PRESETS - onClickPresets.invoke() - }, - modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), - ) - } - item { - TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})") - HorizontalDivider() - } - itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> - HomeRowConfigContent( - config = row, - moveUpAllowed = index > 0, - moveDownAllowed = index != state.rows.lastIndex, - deleteAllowed = state.rows.size > 1, - onClickMove = { - onClickMove.invoke(it, index) - scope.launch { - val scrollIndex = - itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1 - if (scrollIndex < listState.firstVisibleItemIndex || - scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex - ) { - listState.animateScrollToItem(scrollIndex) - } - } - }, - onClickDelete = { - if (index != state.rows.lastIndex) { - focusManager.moveFocus(FocusDirection.Down) - } else { - focusManager.moveFocus(FocusDirection.Up) - } - onClickDelete.invoke(index) - }, - onClick = { - position = itemsBeforeRows + index - onClick.invoke(index, row) - }, - modifier = - Modifier - .fillMaxWidth() - .animateItem() - .focusRequester(focusRequesters[itemsBeforeRows + index]), - ) - } - } - } -} - -@Composable -fun HomeRowConfigContent( - config: HomeRowConfigDisplay, - moveUpAllowed: Boolean, - moveDownAllowed: Boolean, - deleteAllowed: Boolean, - onClick: () -> Unit, - onClickMove: (MoveDirection) -> Unit, - onClickDelete: () -> Unit, - modifier: Modifier, -) { - Box( - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), - ) { - HomeSettingsListItem( - selected = false, - headlineText = config.title.getString(), - onClick = onClick, - modifier = Modifier.weight(1f), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.wrapContentWidth(), - ) { - Button( - onClick = { onClickMove.invoke(MoveDirection.UP) }, - enabled = moveUpAllowed, - ) { - Text( - text = stringResource(R.string.fa_caret_up), - fontFamily = FontAwesome, - ) - } - Button( - onClick = { onClickMove.invoke(MoveDirection.DOWN) }, - enabled = moveDownAllowed, - ) { - Text( - text = stringResource(R.string.fa_caret_down), - fontFamily = FontAwesome, - ) - } - Button( - onClick = onClickDelete, - enabled = deleteAllowed, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "delete", - modifier = Modifier, - ) - } - } - } - } -} - -@Composable -@NonRestartableComposable -fun TitleText( - title: String, - modifier: Modifier = Modifier, -) { - Text( - text = title, - style = MaterialTheme.typography.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, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start, - modifier = - modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt deleted file mode 100644 index 8139b651..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt +++ /dev/null @@ -1,111 +0,0 @@ -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.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -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.services.HomePageSettingsSource -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.tryRequestFocus - -@Composable -fun HomeSettingsSourcePage( - source: HomePageSettingsSource, - onClickSave: () -> Unit, - onClickLoad: () -> Unit, - onClickLoadWeb: () -> Unit, - onClickReset: () -> Unit, - serverPluginActive: Boolean, - onClickLoadPlugin: () -> Unit, - modifier: Modifier = Modifier, -) { - val firstFocus: FocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } - Column(modifier = modifier) { - Text( - text = stringResource(R.string.settings), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .fillMaxHeight() - .focusRestorer(firstFocus), - ) { - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_down), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoad, - modifier = Modifier, - ) - } - if (serverPluginActive) { - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_server_plugin), - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoadPlugin, - modifier = Modifier, - ) - } - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_web_client), - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoadWeb, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.reset), - leadingContent = { - Text( - text = stringResource(R.string.fa_arrows_rotate), - fontFamily = FontAwesome, - ) - }, - onClick = onClickReset, - modifier = Modifier, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt deleted file mode 100644 index 1082a23f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ /dev/null @@ -1,967 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import android.content.Context -import android.widget.Toast -import androidx.compose.runtime.Immutable -import androidx.datastore.core.DataStore -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomePageSettings -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching -import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined -import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres -import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp -import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded -import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased -import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions -import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels -import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.HomeDataService -import com.github.damontecres.wholphin.services.HomePageResolvedSettings -import com.github.damontecres.wholphin.services.HomePageSettingsSource -import com.github.damontecres.wholphin.services.HomeRowConfigDisplay -import com.github.damontecres.wholphin.services.HomeSettingsService -import com.github.damontecres.wholphin.services.NavDrawerService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.services.ServerPluginApi -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.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 -import com.github.damontecres.wholphin.ui.util.ResArgStringProvider -import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider -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.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.serialization.Serializable -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.properties.Delegates - -@HiltViewModel -class HomeSettingsViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val homeSettingsService: HomeSettingsService, - private val homeDataService: HomeDataService, - private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, - private val navDrawerService: NavDrawerService, - private val backdropService: BackdropService, - private val seerrServerRepository: SeerrServerRepository, - private val serverPluginApi: ServerPluginApi, - val preferencesDataStore: DataStore<AppPreferences>, - val navigationManager: NavigationManager, - ) : ViewModel() { - private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) - val state: StateFlow<HomePageSettingsState> = _state - - val serverPluginActive get() = serverRepository.serverPluginInstalled - val currentUser - get() = - serverRepository.currentUserFlow.filterNotNull().stateIn( - viewModelScope, - SharingStarted.Eagerly, - serverRepository.currentUser!!, - ) - val settingsChanged = - state - .map { - HomePageSettings( - rows = it.rows.map { it.config }, - version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, - ) != originalSettings - }.stateIn(viewModelScope, SharingStarted.Eagerly, false) - - private var idCounter by Delegates.notNull<Int>() - - val discoverEnabled = seerrServerRepository.active - - private var originalSettings: HomePageSettings? = null - - init { - viewModelScope.launchIO { - val user = serverRepository.currentUser ?: return@launchIO - val userDto = serverRepository.currentUserDto ?: return@launchIO - val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) - val currentSettings = - homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - originalSettings = currentSettings.asHomePageSettings() - Timber.v("currentSettings=%s", currentSettings) - idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 - _state.update { - it.copy( - source = currentSettings.source, - libraries = libraries, - rows = currentSettings.rows, - ) - } - fetchRowData() - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - private suspend fun fetchRowData() { - val limit = 8 - val semaphore = Semaphore(4) - val rows = - serverRepository.currentUserDto?.let { userDto -> - val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences - state.value - .let { state -> - state.rows - .map { row -> - viewModelScope.async(WholphinDispatchers.IO) { - semaphore.withPermit { - try { - homeDataService.fetchDataForRow( - row = row.config, - scope = viewModelScope, - prefs = prefs, - userDto = userDto, - libraries = state.libraries, - limit = limit, - isRefresh = false, - ) - } catch (ex: Exception) { - Timber.e(ex, "Error on row %s", row) - HomeRowLoadingState.Error(row.title, exception = ex) - } - } - } - } - }.awaitAll() - } - rows?.let { rows -> - rows - .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - it as HomeRowLoadingState.Success - it.items.firstOrNull()?.let { - Timber.v("Updating backdrop") - updateBackdrop(it) - } - } - updateState { - it.copy(loading = LoadingState.Success, rowData = rows) - } - } - } - - private fun <T> List<T>.move( - direction: MoveDirection, - index: Int, - ): List<T> = - toMutableList().apply { - if (direction == MoveDirection.DOWN) { - val down = this[index] - val up = this[index + 1] - set(index, up) - set(index + 1, down) - } else { - val up = this[index] - val down = this[index - 1] - set(index - 1, up) - set(index, down) - } - } - - fun moveRow( - direction: MoveDirection, - index: Int, - ) { - viewModelScope.launchIO { - updateState { - val rows = it.rows.move(direction, index) - val rowData = it.rowData.move(direction, index) - it.copy( - rows = rows, - rowData = rowData, - ) - } - } -// viewModelScope.launchIO { fetchRowData() } - } - - fun deleteRow(index: Int) { - viewModelScope.launchIO { - updateState { - val rows = it.rows.toMutableList().apply { removeAt(index) } - val rowData = - if (index in it.rowData.indices) { - it.rowData.toMutableList().apply { removeAt(index) } - } else { - it.rowData - } - it.copy( - rows = rows, - rowData = rowData, - ) - } - } - } - - fun addRow(type: MetaRowType): Job = - viewModelScope.launchIO { - val id = idCounter++ - val newRow = - when (type) { - MetaRowType.CONTINUE_WATCHING -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.continue_watching), - config = ContinueWatching(), - ) - } - - MetaRowType.NEXT_UP -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.next_up), - config = NextUp(), - ) - } - - MetaRowType.COMBINED_CONTINUE_WATCHING -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.combine_continue_next), - config = ContinueWatchingCombined(), - ) - } - - MetaRowType.FAVORITES, - MetaRowType.COLLECTION, - MetaRowType.PLAYLIST, - -> { - throw IllegalArgumentException("Should use a different addRow() instead") - } - - MetaRowType.DISCOVER -> { - TODO() - } - } - updateState { - it.copy( - loading = LoadingState.Loading, - rows = it.rows.toMutableList().apply { add(newRow) }, - ) - } - fetchRowData() - } - - fun addRow( - library: Library, - rowType: LibraryRowType, - ): Job = - viewModelScope.launchIO { - val viewOptions = viewOptionsForCollectionType(library.collectionType) - val id = idCounter++ - val newRow = - when (rowType) { - LibraryRowType.RECENTLY_ADDED -> { - val title = - library.name.let { - ResArgStringProvider( - R.string.recently_added_in, - it, - ) - } - HomeRowConfigDisplay( - id = id, - title = title, - config = RecentlyAdded(library.itemId, viewOptions), - ) - } - - LibraryRowType.RECENTLY_RELEASED -> { - val title = - library.name.let { - ResArgStringProvider( - R.string.recently_released_in, - it, - ) - } - HomeRowConfigDisplay( - id = id, - title = title, - config = RecentlyReleased(library.itemId, viewOptions), - ) - } - - LibraryRowType.GENRES -> { - val title = - library.name.let { ResArgStringProvider(R.string.genres_in, it) } - HomeRowConfigDisplay( - id = id, - title = title, - config = Genres(library.itemId), - ) - } - - LibraryRowType.STUDIOS -> { - val title = - library.name.let { ResArgStringProvider(R.string.studios_in, it) } - HomeRowConfigDisplay( - id = id, - title = title, - config = HomeRowConfig.Studios(library.itemId), - ) - } - - LibraryRowType.SUGGESTIONS -> { - val title = - library.name.let { - ResArgStringProvider( - R.string.suggestions_for, - it, - ) - } - HomeRowConfigDisplay( - id = id, - title = title, - config = Suggestions(library.itemId, viewOptions), - ) - } - - LibraryRowType.TV_CHANNELS -> { - val title = ResStringProvider(R.string.channels) - HomeRowConfigDisplay( - id = id, - title = title, - config = - TvChannels( - viewOptions = HomeRowViewOptions.liveTvDefault, - ), - ) - } - - LibraryRowType.TV_PROGRAMS -> { - val title = ResStringProvider(R.string.watch_live) - HomeRowConfigDisplay( - id = id, - title = title, - config = TvPrograms(), - ) - } - - LibraryRowType.RECENTLY_RECORDED -> { - val title = getRecentlyAddedTitle(library.name) - HomeRowConfigDisplay( - id = id, - title = title, - config = RecentlyAdded(library.itemId), - ) - } - - LibraryRowType.COLLECTION, - LibraryRowType.PLAYLIST, - -> { - throw IllegalArgumentException("Use different addRow") - } - } - updateState { - it.copy( - loading = LoadingState.Loading, - rows = it.rows.toMutableList().apply { add(newRow) }, - ) - } - fetchRowData() - } - - fun addFavoriteRow(type: BaseItemKind): Job = - viewModelScope.launchIO { - Timber.v("Adding favorite row for $type") - val id = idCounter++ - val newRow = - HomeRowConfigDisplay( - id = id, - title = - ResProviderStringProvider( - R.string.favorite_items_title, - ResStringProvider(formatTypeName(type)), - ), - config = - HomeRowConfig.Favorite( - kind = type, - viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type), - ), - ) - updateState { - it.copy( - loading = LoadingState.Loading, - rows = it.rows.toMutableList().apply { add(newRow) }, - ) - } - fetchRowData() - } - - fun addRow( - type: BaseItemKind, - parent: BaseItem, - ) = viewModelScope.launchIO { - Timber.v("Adding %s row for %s", type, parent.id) - val id = idCounter++ - val newRow = - HomeRowConfigDisplay( - id = id, - title = StringStringProvider(parent.name ?: ""), - config = - HomeRowConfig.ByParent( - parentId = parent.id, - recursive = false, - ), - ) - updateState { - it.copy( - loading = LoadingState.Loading, - rows = it.rows.toMutableList().apply { add(newRow) }, - ) - } - fetchRowData() - } - - fun updateViewOptions( - rowId: Int, - viewOptions: HomeRowViewOptions, - ) { - viewModelScope.launchIO { - var fetchData = false - updateState { - val index = it.rows.indexOfFirst { it.id == rowId } - val config = it.rows[index].config - val newRowConfig = config.updateViewOptions(viewOptions) - val newRow = it.rows[index].copy(config = newRowConfig) - if (config.viewOptions.useSeries != viewOptions.useSeries) { - fetchData = true - } - it.copy( - rows = - it.rows.toMutableList().apply { - set(index, newRow) - }, - rowData = - it.rowData.toMutableList().apply { - val row = it.rowData[index] - val newRow = - if (row is HomeRowLoadingState.Success) { - row.copy(viewOptions = viewOptions) - } else { - row - } - set(index, newRow) - }, - ) - } - if (fetchData) { - fetchRowData() - } - } - } - - fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) { - viewModelScope.launchIO { - updateState { - it.copy( - rowData = - it.rowData.toMutableList().map { row -> - if (row is HomeRowLoadingState.Success) { - row.copy(viewOptions = viewOptions) - } else { - row - } - }, - ) - } - } - } - - fun saveToRemote(): Job = - viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> - Timber.d("Saving home settings to remote") - val rows = state.value.rows.map { it.config } - val settings = - HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) - try { - Timber.d("saveToRemote") - homeSettingsService.saveToServer(user.id, settings) - homeSettingsService.updateCurrent( - HomePageSettingsSource.SERVER_PROFILE, - settings, - ) - showSaveToast() - } catch (ex: Exception) { - Timber.e(ex) - showToast(context, "Error saving: ${ex.localizedMessage}") - } - } - } - - fun loadFromRemote() { - viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> - Timber.d("Loading home settings from remote") - try { - _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.loadFromServer(user.id) - if (result != null) { - Timber.v("Got remote settings") - val newRows = - result.rows.mapIndexed { index, config -> - homeSettingsService.resolve(index, config) - } - idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 - _state.update { - it.copy( - source = HomePageSettingsSource.SERVER_PROFILE, - rows = newRows, - ) - } - } else { - Timber.v("No remote settings") - showToast(context, "No server-side settings found") - } - fetchRowData() - } catch (ex: UnsupportedHomeSettingsVersionException) { - // TODO - Timber.w(ex) - showToast(context, "Error: ${ex.localizedMessage}") - } catch (ex: Exception) { - Timber.e(ex) - showToast(context, "Error: ${ex.localizedMessage}") - } - } - } - } - - fun loadFromRemoteWeb() { - viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> - Timber.d("Loading home settings from web") - try { - _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.parseFromWebConfig(user.id) - if (result != null) { - Timber.v("Got web settings") - idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 - _state.update { - it.copy( - source = HomePageSettingsSource.LOCAL, - rows = result.rows, - ) - } - } else { - Timber.v("No web settings") - showToast(context, "No server-side web settings found") - } - fetchRowData() - } catch (ex: Exception) { - Timber.e(ex) - showToast(context, "Error: ${ex.localizedMessage}") - } - } - } - } - - fun saveToLocal(): Job = - viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> - val rows = state.value.rows.map { it.config } - val settings = - HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) - try { - Timber.d("saveToLocal") - homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent( - HomePageSettingsSource.LOCAL, - settings, - ) - showSaveToast() - } catch (ex: Exception) { - Timber.e(ex) - showToast(context, "Error saving: ${ex.localizedMessage}") - } - } - } - - private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { - _state.update { - update.invoke(it) - } - } - - fun resizeCards(relative: Int) { - viewModelScope.launchIO { - updateState { - val newRows = - it.rows.toMutableList().map { row -> - val vo = row.config.viewOptions - val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative)) - row.copy(config = row.config.updateViewOptions(newVo)) - } - it.copy( - rows = newRows, - rowData = - it.rowData.toMutableList().mapIndexed { index, row -> - if (row is HomeRowLoadingState.Success) { - row.copy(viewOptions = newRows[index].config.viewOptions) - } else { - row - } - }, - ) - } - } - } - - fun resetToDefault() = - viewModelScope.launchIO { - val user = serverRepository.currentUser ?: return@launchIO - _state.update { it.copy(loading = LoadingState.Loading) } - val result = - if (serverPluginActive.value) { - try { - val settings = - homeSettingsService.fetchSettingsFor( - user.id, - HomePageSettingsSource.PLUGIN, - ) - settings - } catch (ex: Exception) { - Timber.e(ex, "Plugin is active, but error fetching home settings") - null - } - } else { - null - } ?: homeSettingsService.createDefault(user.id) - - idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 - _state.update { - it.copy( - source = result.source, - rows = result.rows, - rowData = emptyList(), - ) - } - fetchRowData() - } - - private suspend fun showSaveToast() = - showToast( - context, - context.getString(R.string.settings_saved), - Toast.LENGTH_SHORT, - ) - - fun applyPreset(preset: HomeRowPresets) { - _state.update { it.copy(loading = LoadingState.Loading) } - viewModelScope.launchIO { - val state = state.value - - val typeCache = mutableMapOf<UUID, CollectionType?>() - - suspend fun getCollectionType(itemId: UUID): CollectionType = - typeCache.getOrPut(itemId) { - state.libraries - .firstOrNull { it.itemId == itemId } - ?.collectionType - ?: api.userLibraryApi - .getItem(itemId) - .content.collectionType ?: CollectionType.UNKNOWN - } ?: CollectionType.UNKNOWN - - val newRows = - state.rows.map { - val newConfig = - when (it.config) { - is ContinueWatching, - is NextUp, - is ContinueWatchingCombined, - -> { - it.config.updateViewOptions(preset.continueWatching) - } - - is HomeRowConfig.ByParent -> { - val collectionType = getCollectionType(it.config.parentId) - val viewOptions = preset.getByCollectionType(collectionType) - it.config.updateViewOptions(viewOptions) - } - - is HomeRowConfig.Favorite -> { - val viewOptions = preset.getByItemType(it.config.kind) - it.config.updateViewOptions(viewOptions) - } - - is Genres -> { - it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) - } - - is HomeRowConfig.Studios -> { - it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) - } - - is HomeRowConfig.GetItems -> { - it.config - } - - is RecentlyAdded -> { - val collectionType = getCollectionType(it.config.parentId) - val viewOptions = preset.getByCollectionType(collectionType) - it.config.updateViewOptions(viewOptions) - } - - is RecentlyReleased -> { - val collectionType = getCollectionType(it.config.parentId) - val viewOptions = preset.getByCollectionType(collectionType) - it.config.updateViewOptions(viewOptions) - } - - is HomeRowConfig.Recordings -> { - it.config.updateViewOptions(preset.tvLibrary) - } - - is Suggestions -> { - val collectionType = getCollectionType(it.config.parentId) - val viewOptions = preset.getByCollectionType(collectionType) - it.config.updateViewOptions(viewOptions) - } - - is HomeRowConfig.TvPrograms -> { - it.config.updateViewOptions(preset.liveTv) - } - - is HomeRowConfig.TvChannels -> { - it.config.updateViewOptions(preset.liveTv) - } - - is HomeRowConfig.CustomEndpoint -> { - it.config - } - } - it.copy(config = newConfig) - } - - _state.update { - it.copy( - loading = LoadingState.Success, - rows = newRows, - rowData = - it.rowData.toMutableList().mapIndexed { index, row -> - if (row is HomeRowLoadingState.Success) { - row.copy(viewOptions = newRows[index].config.viewOptions) - } else { - row - } - }, - ) - } - } - } - - fun loadFromRemotePlugin() { - viewModelScope.launchIO { - Timber.d("Loading home settings from server plugin") - try { - // TODO should this remove local settings? - // TODO how to set up priorities - _state.update { it.copy(loading = LoadingState.Loading) } - val result = serverPluginApi.fetchHomePageSettings() - if (result != null) { - Timber.v("Got remote settings") - val newRows = - result.rows.mapIndexed { index, config -> - homeSettingsService.resolve(index, config) - } - idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 - _state.update { - it.copy( - source = HomePageSettingsSource.PLUGIN, - rows = newRows, - ) - } - } else { - Timber.v("No server plugin settings") - showToast(context, "No server plugin settings found") - } - fetchRowData() - } catch (ex: UnsupportedHomeSettingsVersionException) { - // TODO - Timber.w(ex) - showToast(context, "Error: ${ex.localizedMessage}") - } catch (ex: Exception) { - Timber.e(ex) - showToast(context, "Error: ${ex.localizedMessage}") - } - } - } - - fun onConfigAction( - row: HomeRowConfigDisplay, - action: HomeRowConfigAction, - ) { - viewModelScope.launchDefault { - when (action) { - HomeRowConfigAction.Combine -> { - val index = - state.value.rows - .indexOf(row) - .coerceAtLeast(0) - val rowsToRemove = - state.value.rows - .filter { it.config is ContinueWatching || it.config is NextUp } - - updateState { - it.copy( - loading = LoadingState.Loading, - rows = - it.rows.toMutableList().apply { - set( - index, - HomeRowConfigDisplay( - id = it.rows[index].id, - title = ResStringProvider(R.string.combine_continue_next), - config = ContinueWatchingCombined(row.config.viewOptions), - ), - ) - removeAll(rowsToRemove) - }, - ) - } - fetchRowData() - } - - HomeRowConfigAction.Split -> { - val index = - state.value.rows - .indexOf(row) - .coerceAtLeast(0) - updateState { - it.copy( - loading = LoadingState.Loading, - rows = - it.rows.toMutableList().apply { - set( - index, - HomeRowConfigDisplay( - id = it.rows[index].id, - title = ResStringProvider(R.string.continue_watching), - config = ContinueWatching(row.config.viewOptions), - ), - ) - add( - index + 1, - HomeRowConfigDisplay( - id = idCounter++, - title = ResStringProvider(R.string.next_up), - config = NextUp(row.config.viewOptions), - ), - ) - }, - ) - } - fetchRowData() - } - } - } - } - - fun onConfigChange( - row: HomeRowConfigDisplay, - config: HomeRowConfig, - ) { - viewModelScope.launchDefault { - val index = - state.value.rows - .indexOf(row) - .coerceAtLeast(0) - updateState { - it.copy( - loading = LoadingState.Loading, - rows = - it.rows.toMutableList().apply { - set( - index, - row.copy(config = config), - ) - }, - ) - } - fetchRowData() - } - } - - fun discardChanges(): Job = Job().apply { complete() } - } - -data class HomePageSettingsState( - val loading: LoadingState, - val source: HomePageSettingsSource, - val rows: List<HomeRowConfigDisplay>, - val rowData: List<HomeRowLoadingState>, - val libraries: List<Library>, -) { - companion object { - val EMPTY = - HomePageSettingsState( - LoadingState.Pending, - HomePageSettingsSource.UNSET, - emptyList(), - emptyList(), - emptyList(), - ) - } -} - -@Immutable -@Serializable -data class Library( - @Serializable(UUIDSerializer::class) val itemId: UUID, - val name: String, - val type: BaseItemKind, - val collectionType: CollectionType, - val isRecordingFolder: Boolean, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt deleted file mode 100644 index e7b3c07c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt +++ /dev/null @@ -1,258 +0,0 @@ -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.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.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.Icon -import androidx.tv.material3.ListItem -import androidx.tv.material3.ListItemDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.services.LatestNextUpService -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.update -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.model.api.ImageType -import timber.log.Timber -import java.time.LocalDateTime -import javax.inject.Inject - -@HiltViewModel -class RemovedNextUpContentViewModel - @Inject - constructor( - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - ) : ViewModel() { - private val mutex = Mutex() - - private val _state = MutableStateFlow(RemovedNextUpState()) - val state: StateFlow<RemovedNextUpState> = _state - - init { - viewModelScope.launchDefault { - serverRepository.currentUserFlow.collectLatest { user -> - _state.update { RemovedNextUpState() } - if (user == null) { - return@collectLatest - } - try { - val removed = latestNextUpService.getRemovedFromNextUp(user.id) - val series = mutableListOf<RemovedItem>() - removed.keys.chunked(50).forEach { ids -> - val results = - api.itemsApi - .getItems( - userId = user.id, - ids = ids, - ).toBaseItems(api, false) - results.forEach { - val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY) - series.add(RemovedItem(it, imageUrl, removed[it.id]!!)) - } - } - _state.update { it.copy(loading = DataLoadingState.Success(series)) } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching removed series") - _state.update { it.copy(loading = DataLoadingState.Error(ex)) } - } - } - } - } - - fun remove(item: RemovedItem) { - serverRepository.currentUser?.let { user -> - viewModelScope.launchDefault { - mutex.withLock { - _state.update { it.copy(removedEnabled = false) } - try { - latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id) - val newItems = - (_state.value.loading as? DataLoadingState.Success<List<RemovedItem>>) - ?.data - ?.toMutableList() - ?.apply { - removeIf { it.series.id == item.series.id } - } - val loading = - if (newItems != null) { - DataLoadingState.Success(newItems) - } else { - DataLoadingState.Error("Error occurred") - } - _state.update { - it.copy( - loading = loading, - removedEnabled = true, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error removing %s from removed next up", item.series.id) - } finally { - _state.update { it.copy(removedEnabled = true) } - } - } - } - } - } - } - -@Stable -data class RemovedItem( - val series: BaseItem, - val imageUrl: String?, - val datetime: LocalDateTime, -) - -data class RemovedNextUpState( - val loading: DataLoadingState<List<RemovedItem>> = DataLoadingState.Pending, - val removedEnabled: Boolean = true, -) - -@Composable -fun RemovedNextUpContent( - modifier: Modifier = Modifier, - viewModel: RemovedNextUpContentViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsState() - Column( - modifier = modifier, - ) { - Text( - text = "Removed from next up", - style = MaterialTheme.typography.displaySmall, - ) - when (val s = state.loading) { - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage(Modifier.fillMaxWidth()) - } - - is DataLoadingState.Error -> { - ErrorMessage(s, Modifier.fillMaxWidth()) - } - - is DataLoadingState.Success<List<RemovedItem>> -> { - if (s.data.isEmpty()) { - Text( - text = stringResource(R.string.no_results), - ) - } else { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - LazyColumn( - contentPadding = PaddingValues(horizontal = 8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - ) { - items(s.data, key = { it.series.id }) { item -> - RemovedListItem( - item = item, - removedEnabled = state.removedEnabled, - onClickRemove = - remember { - { viewModel.remove(item) } - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } - } - } -} - -@Composable -fun RemovedListItem( - item: RemovedItem, - removedEnabled: Boolean, - onClickRemove: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - ListItem( - selected = false, - onClick = {}, - leadingContent = { - AsyncImage( - model = item.imageUrl, - contentDescription = item.series.title, - modifier = Modifier.height(80.dp), - ) - }, - headlineContent = { - Text( - text = item.series.title ?: item.series.id.toString(), - ) - }, - supportingContent = { - Text( - text = formatDateTime(item.datetime), - ) - }, - scale = ListItemDefaults.scale(focusedScale = 1f), - modifier = Modifier.weight(1f), - ) - Button( - onClick = onClickRemove, - enabled = removedEnabled, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "delete", - modifier = Modifier, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt deleted file mode 100644 index c7531b6d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup - -@Composable -fun SaveHomeSettingsDialog( - onDismissRequest: () -> Unit, - onSaveLocal: () -> Unit, - onSaveServer: () -> Unit, - onDiscard: () -> Unit, -) { - val resources = LocalResources.current - val params = - remember(resources) { - DialogParams( - fromLongClick = false, - title = resources.getString(R.string.save), - items = - listOf( - DialogItem( - text = R.string.save_to_local, - iconStringRes = R.string.fa_cloud_arrow_down, - onClick = onSaveLocal, - ), - DialogItem( - text = R.string.save_to_server, - iconStringRes = R.string.fa_cloud_arrow_up, - onClick = onSaveServer, - ), - DialogItem( - text = "Discard", - icon = Icons.Default.Delete, - iconColor = Color.Red, - onClick = onDiscard, - ), - ), - ) - } - DialogPopup( - params = params, - onDismissRequest = onDismissRequest, - elevation = 3.dp, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 0cf42622..ddd881b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -1,43 +1,18 @@ package com.github.damontecres.wholphin.ui.nav -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import androidx.tv.material3.DrawerValue -import androidx.tv.material3.rememberDrawerState import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser 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.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.launchIO -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -// Top scrim configuration for text readability (clock, season tabs) -const val TOP_SCRIM_ALPHA = 0.55f -const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height - -@HiltViewModel -class ApplicationContentViewModel - @Inject - constructor( - val backdropService: BackdropService, - ) : ViewModel() { - fun clearBackdrop() { - viewModelScope.launchIO { backdropService.clearBackdrop() } - } - } +import org.jellyfin.sdk.model.api.DeviceProfile /** * This is generally the root composable of the of the app @@ -46,61 +21,45 @@ class ApplicationContentViewModel */ @Composable fun ApplicationContent( - server: JellyfinServer, - user: JellyfinUser, + server: JellyfinServer?, + user: JellyfinUser?, navigationManager: NavigationManager, preferences: UserPreferences, + deviceProfile: DeviceProfile, modifier: Modifier = Modifier, - enableTopScrim: Boolean = true, - viewModel: ApplicationContentViewModel = hiltViewModel(), ) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - Box( - modifier = modifier, - ) { - val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle - Backdrop( - drawerIsOpen = drawerState.isOpen, - backdropStyle = backdropStyle, - enableTopScrim = enableTopScrim, - viewModel = viewModel, - ) - val navDrawerListState = rememberLazyListState() - NavDisplay( - backStack = navigationManager.backStack, - onBack = { navigationManager.goBack() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as Destination - val contentKey = "${key}_${server?.id}_${user?.id}" - NavEntry(key, contentKey = contentKey) { - if (key.fullScreen) { - DestinationContent( - destination = key, - preferences = preferences, - onClearBackdrop = viewModel::clearBackdrop, - modifier = Modifier.fillMaxSize(), - ) - } else if (user != null && server != null) { - NavDrawer( - destination = key, - preferences = preferences, - user = user, - server = server, - drawerState = drawerState, - navDrawerListState = navDrawerListState, - onClearBackdrop = viewModel::clearBackdrop, - modifier = Modifier.fillMaxSize(), - ) - } else { - ErrorMessage("Trying to go to $key without a user logged in", null) - } + NavDisplay( + backStack = navigationManager.backStack, + onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as Destination + val contentKey = "${key}_${server?.id}_${user?.id}" + NavEntry(key, contentKey = contentKey) { + if (key.fullScreen) { + DestinationContent( + destination = key, + preferences = preferences, + deviceProfile = deviceProfile, + modifier = modifier.fillMaxSize(), + ) + } else if (user != null && server != null) { + NavDrawer( + destination = key, + preferences = preferences, + deviceProfile = deviceProfile, + user = user, + server = server, + modifier = modifier, + ) + } else { + ErrorMessage("Trying to go to $key without a user logged in", null) } - }, - ) - } + } + }, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt deleted file mode 100644 index 188ad347..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Backdrop.kt +++ /dev/null @@ -1,217 +0,0 @@ -@file:OptIn(ExperimentalCoilApi::class) - -package com.github.damontecres.wholphin.ui.nav - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.tv.material3.MaterialTheme -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.AsyncImage -import coil3.compose.useExistingImageAsPlaceholder -import coil3.request.ImageRequest -import coil3.request.transitionFactory -import com.github.damontecres.wholphin.preferences.BackdropStyle -import com.github.damontecres.wholphin.services.BackdropResult -import com.github.damontecres.wholphin.ui.CrossFadeFactory -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService] - */ -@Composable -fun Backdrop( - drawerIsOpen: Boolean, - backdropStyle: BackdropStyle, - viewModel: ApplicationContentViewModel = hiltViewModel(), - modifier: Modifier = Modifier, - enableTopScrim: Boolean = true, - useExistingImageAsPlaceholder: Boolean = false, - crossfadeDuration: Duration = 800.milliseconds, -) { - val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() - Backdrop( - backdrop = backdrop, - drawerIsOpen = drawerIsOpen, - backdropStyle = backdropStyle, - modifier = modifier, - enableTopScrim = enableTopScrim, - useExistingImageAsPlaceholder = useExistingImageAsPlaceholder, - crossfadeDuration = crossfadeDuration, - ) -} - -/** - * Shows the current backdrop images provided by the [BackdropResult] - */ -@Composable -fun Backdrop( - backdrop: BackdropResult, - drawerIsOpen: Boolean, - backdropStyle: BackdropStyle, - modifier: Modifier = Modifier, - enableTopScrim: Boolean = true, - useExistingImageAsPlaceholder: Boolean = false, - crossfadeDuration: Duration = 800.milliseconds, -) { - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - val baseBackgroundColor = MaterialTheme.colorScheme.background - if (backdrop.hasColors && - (backdropStyle == BackdropStyle.BACKDROP_DYNAMIC_COLOR || backdropStyle == BackdropStyle.UNRECOGNIZED) - ) { - val animPrimary by animateColorAsState( - backdrop.primaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_primary", - ) - val animSecondary by animateColorAsState( - backdrop.secondaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_secondary", - ) - val animTertiary by animateColorAsState( - backdrop.tertiaryColor, - animationSpec = tween(1250), - label = "dynamic_backdrop_tertiary", - ) - Box( - modifier = - modifier - .fillMaxSize() - .drawBehind { - drawRect(color = baseBackgroundColor) - val start = if (isRtl) size.width else 0f - val end = if (isRtl) 0f else size.width - // Top Left (Vibrant/Muted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animSecondary, Color.Transparent), - center = Offset(start, 0f), - radius = size.width * 0.8f, - ), - ) - // Bottom Right (DarkVibrant/DarkMuted) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animPrimary, Color.Transparent), - center = Offset(end, size.height), - radius = size.width * 0.8f, - ), - ) - // Bottom Left (Dark / Bridge) - drawRect( - brush = - Brush.radialGradient( - colors = - listOf( - baseBackgroundColor, - Color.Transparent, - ), - center = Offset(start, size.height), - radius = size.width * 0.8f, - ), - ) - // Top Right (Under Image - Vibrant/Bright) - drawRect( - brush = - Brush.radialGradient( - colors = listOf(animTertiary, Color.Transparent), - center = Offset(end, 0f), - radius = size.width * 0.8f, - ), - ) - }, - ) - } - if (backdropStyle != BackdropStyle.BACKDROP_NONE) { - Box( - modifier = modifier.fillMaxSize(), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(backdrop.imageUrl) - .useExistingImageAsPlaceholder(useExistingImageAsPlaceholder) - .transitionFactory(CrossFadeFactory(crossfadeDuration)) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - Modifier - .align(Alignment.TopEnd) - .fillMaxHeight(.7f) - .fillMaxWidth(.7f) - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - val start = if (isRtl) size.width else 0f - drawContent() - if (drawerIsOpen) { - drawRect( - brush = SolidColor(Color.Black), - alpha = .75f, - ) - } - // Subtle top scrim for system UI readability (clock, tabs) - if (enableTopScrim) { - drawRect( - brush = - Brush.verticalGradient( - colorStops = - arrayOf( - 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), - TOP_SCRIM_END_FRACTION to Color.Transparent, - ), - ), - blendMode = BlendMode.Multiply, - ) - } - drawRect( - brush = - Brush.horizontalGradient( - colors = listOf(Color.Transparent, Color.Black), - startX = start, - endX = size.width * 0.6f, - ), - blendMode = BlendMode.DstIn, - ) - drawRect( - brush = - Brush.verticalGradient( - colors = listOf(Color.Black, Color.Transparent), - startY = 0f, - endY = size.height, - ), - blendMode = BlendMode.DstIn, - ) - }, - ) - } - } -} 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 5bef2675..9310d0fa 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 @@ -2,27 +2,20 @@ package com.github.damontecres.wholphin.ui.nav +import androidx.annotation.StringRes 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 import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.util.StringProvider -import com.github.damontecres.wholphin.util.DiscoverRequestType -import com.github.damontecres.wholphin.util.RequestHandler -import com.github.damontecres.wholphin.util.SEERR_PAGE_SIZE -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -36,37 +29,31 @@ sealed class Destination( val fullScreen: Boolean = false, ) : NavKey { @Serializable - data class Wolfskin(val route: String) : Destination(false) + data object ServerList : Destination(true) + + @Serializable + data class UserList( + val server: JellyfinServer, + ) : Destination(true) @Serializable data class Home( val id: Long = 0L, ) : Destination() - @Serializable - data object HomeSettings : Destination(true) - @Serializable data class Settings( val screen: PreferenceScreenOption, ) : Destination(true) @Serializable - data class SubtitleSettings( - val hdr: Boolean, - ) : Destination(true) - - @Serializable - data class Search( - val query: String = "", - ) : Destination() - - data object UserAppPreferences : Destination(true) + data object Search : Destination() @Serializable data class SeriesOverview( val itemId: UUID, val type: BaseItemKind, + @Transient val item: BaseItem? = null, val seasonEpisode: SeasonEpisodeIds? = null, ) : Destination() { override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)" @@ -76,10 +63,11 @@ sealed class Destination( data class MediaItem( val itemId: UUID, val type: BaseItemKind, - val collectionType: CollectionType? = null, - val initialSongId: UUID? = null, + @Transient val item: BaseItem? = null, + val seasonEpisode: SeasonEpisode? = null, ) : Destination() { - constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) + override fun toString(): String = + "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})" } @Serializable @@ -91,11 +79,13 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, + @Transient val item: BaseItem? = null, + val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, - val backend: PlayerBackend? = null, - val shuffle: Boolean = false, ) : Destination(true) { - constructor(item: BaseItem) : this(item.id, item.resumeMs) + override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" + + constructor(item: BaseItem) : this(item.id, item.resumeMs, item) } @Serializable @@ -113,62 +103,20 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, - val parentType: BaseItemKind, - val filter: CollectionFolderFilter, + val filter: GetItemsFilter, val recursive: Boolean, - val collectionType: CollectionType, ) : Destination(false) @Serializable - data class ItemGrid<T>( - val title: StringProvider, - @Contextual val request: T, - val requestHandler: RequestHandler<T>, - val initialPosition: Int = 0, - val viewOptions: ViewOptions = ViewOptions(), + data class ItemGrid( + val title: String?, + @param:StringRes val titleRes: Int?, + val itemIds: List<UUID>, ) : Destination(false) - @Serializable - data class MoreHomeRow( - val title: StringProvider, - val config: HomeRowConfig, - val initialPosition: Int, - ) : Destination(false) - - @Serializable - data class Slideshow( - val parentId: UUID?, - val index: Int, - val filter: CollectionFolderFilter, - val sortAndDirection: SortAndDirection, - val recursive: Boolean, - val startSlideshow: Boolean, - ) : Destination(true) - @Serializable data object Favorites : Destination(false) - @Serializable - data object Discover : Destination(false) - - @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 - data object NowPlaying : Destination(true) - @Serializable data object UpdateApp : Destination(true) 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 3e449875..b3bc2fce 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 @@ -1,58 +1,36 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions -import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions -import com.github.damontecres.wholphin.data.model.SeerrItemType -import com.github.damontecres.wholphin.preferences.PlayerBackend 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.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie -import com.github.damontecres.wholphin.ui.detail.CollectionFolderMusic -import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage -import com.github.damontecres.wholphin.ui.detail.HomeRowGrid import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails -import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails -import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails -import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage -import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails -import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails 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.settings.HomeSettingsPage -import com.github.damontecres.wholphin.ui.playback.PlayExternalPage +import com.github.damontecres.wholphin.ui.main.SearchPage 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 com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.DeviceProfile import timber.log.Timber /** @@ -62,238 +40,127 @@ import timber.log.Timber fun DestinationContent( destination: Destination, preferences: UserPreferences, - onClearBackdrop: () -> Unit, + deviceProfile: DeviceProfile, modifier: Modifier = Modifier, ) { - if (destination.fullScreen) { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - } when (destination) { - is Destination.Wolfskin -> { - org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier) - } - - is Destination.Home -> { + is Destination.Home -> HomePage( preferences = preferences, modifier = modifier, ) - } + is Destination.PlaybackList, + is Destination.Playback, + -> + PlaybackPage( + preferences = preferences, + deviceProfile = deviceProfile, + destination = destination, + modifier = modifier, + ) - is Destination.HomeSettings -> { - HomeSettingsPage(preferences, modifier) - } + Destination.ServerList -> SwitchServerContent(modifier) + is Destination.UserList -> SwitchUserContent(destination.server, modifier) - is Destination.Playback -> { - val backend = - destination.backend ?: preferences.appPreferences.playbackPreferences.playerBackend - if (backend == PlayerBackend.EXTERNAL_PLAYER) { - PlayExternalPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } else { - PlaybackPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } - } - - is Destination.PlaybackList -> { - if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) { - PlayExternalPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } else { - PlaybackPage( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } - } - - is Destination.Settings -> { + is Destination.Settings -> PreferencesPage( preferences.appPreferences, destination.screen, modifier, ) - } - is Destination.SubtitleSettings -> { - SubtitleStylePage( - preferences.appPreferences, - destination.hdr, - modifier, - ) - } - - Destination.UserAppPreferences -> { - UserProfilePreferencesPage(modifier) - } - - is Destination.SeriesOverview -> { + is Destination.SeriesOverview -> SeriesOverview( - preferences = preferences, - destination = destination, + preferences, + destination, + modifier, initialSeasonEpisode = destination.seasonEpisode, - modifier = modifier, ) - } - is Destination.MediaItem -> { + is Destination.MediaItem -> when (destination.type) { - BaseItemKind.SERIES -> { + BaseItemKind.SERIES -> SeriesDetails( preferences, destination, modifier, ) - } - BaseItemKind.MOVIE -> { + BaseItemKind.MOVIE -> MovieDetails( preferences, destination, modifier, ) - } - BaseItemKind.VIDEO, - BaseItemKind.MUSIC_VIDEO, - -> { + BaseItemKind.VIDEO -> // TODO Use VideoDetails MovieDetails( preferences, destination, modifier, ) - } - BaseItemKind.EPISODE -> { - EpisodeDetails( - preferences, - destination, - modifier, - ) - } - - BaseItemKind.BOX_SET -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionDetails( + BaseItemKind.BOX_SET -> + CollectionFolderBoxSet( preferences = preferences, itemId = destination.itemId, + item = destination.item, + recursive = false, + playEnabled = true, modifier = modifier, ) - } - BaseItemKind.PLAYLIST -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + BaseItemKind.PLAYLIST -> PlaylistDetails( - preferences = preferences, destination = destination, modifier = modifier, ) - } - BaseItemKind.COLLECTION_FOLDER -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + BaseItemKind.COLLECTION_FOLDER -> CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.collectionType, + collectionType = destination.item?.data?.collectionType, usePostersOverride = null, recursiveOverride = null, modifier = modifier, ) - } - BaseItemKind.FOLDER -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + BaseItemKind.FOLDER -> CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.collectionType, + collectionType = destination.item?.data?.collectionType, usePostersOverride = true, recursiveOverride = null, modifier = modifier, ) - } - BaseItemKind.USER_VIEW -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + BaseItemKind.USER_VIEW -> CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.collectionType, + collectionType = destination.item?.data?.collectionType, usePostersOverride = null, recursiveOverride = true, modifier = modifier, ) - } - BaseItemKind.PERSON -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + BaseItemKind.PERSON -> PersonPage( preferences, destination, modifier, ) - } - - BaseItemKind.PHOTO_ALBUM -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderPhotoAlbum( - preferences = preferences, - itemId = destination.itemId, - recursive = false, - modifier = modifier, - ) - } - - BaseItemKind.MUSIC_ALBUM -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - AlbumDetailsPage( - preferences = preferences, - itemId = destination.itemId, - initialSongId = destination.initialSongId, - modifier = modifier, - ) - } - - BaseItemKind.MUSIC_ARTIST -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - ArtistDetailsPage( - preferences = preferences, - itemId = destination.itemId, - modifier = modifier, - ) - } - - 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) + Text("Unsupported item type: ${destination.type}") } } - } - is Destination.FilteredCollection -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + is Destination.FilteredCollection -> CollectionFolderGeneric( preferences = preferences, itemId = destination.itemId, @@ -301,137 +168,41 @@ fun DestinationContent( recursive = destination.recursive, usePosters = true, playEnabled = true, // TODO only genres use this currently, so might need to change in future - filterOptions = - when (destination.parentType) { - BaseItemKind.GENRE -> DefaultForGenresFilterOptions - BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions - else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") - }, - sortOptions = rememberSortOptions(destination.collectionType), + filterOptions = DefaultForGenresFilterOptions, modifier = modifier, ) - } - is Destination.MoreHomeRow -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - HomeRowGrid( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } - - is Destination.Recordings -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + is Destination.Recordings -> CollectionFolderRecordings( preferences, destination.itemId, false, modifier, ) - } - is Destination.ItemGrid<*> -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + is Destination.ItemGrid -> ItemGrid( - preferences, destination, modifier, ) - } - is Destination.Slideshow -> { - SlideshowPage( - slideshow = destination, - ) - } - - Destination.Favorites -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + Destination.Favorites -> FavoritesPage( preferences = preferences, modifier = modifier, ) - } - Destination.NowPlaying -> { - NowPlayingPage(modifier) - } + Destination.UpdateApp -> InstallUpdatePage(preferences, modifier) - Destination.UpdateApp -> { - InstallUpdatePage(preferences, modifier) - } + Destination.License -> LicenseInfo(modifier) - Destination.License -> { - LicenseInfo(modifier) - } - - is Destination.Search -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } + Destination.Search -> SearchPage( - initialQuery = destination.query, userPreferences = preferences, modifier = modifier, ) - } - Destination.Debug -> { - DebugPage(preferences, modifier) - } - - Destination.Discover -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - DiscoverPage( - preferences = preferences, - modifier = modifier, - ) - } - - is Destination.DiscoveredItem -> { - when (destination.item.type) { - SeerrItemType.MOVIE -> { - DiscoverMovieDetails( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } - - SeerrItemType.TV -> { - DiscoverSeriesDetails( - preferences = preferences, - destination = destination, - modifier = modifier, - ) - } - - SeerrItemType.PERSON -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - DiscoverPersonPage( - person = destination.item, - startIndex = destination.startIndex, - modifier = modifier, - ) - } - - SeerrItemType.UNKNOWN -> { - Text( - text = "Unknown discover type", - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - - is Destination.DiscoverMoreResult -> { - LaunchedEffect(Unit) { onClearBackdrop.invoke() } - DiscoverRequestGrid( - destination = destination, - showTitle = true, - positionCallback = { _, _ -> }, - modifier = modifier, - ) - } + Destination.Debug -> DebugPage(preferences, modifier) } } @@ -445,73 +216,51 @@ fun CollectionFolder( modifier: Modifier = Modifier, ) { when (collectionType) { - CollectionType.TVSHOWS -> { + CollectionType.TVSHOWS -> CollectionFolderTv( preferences, destination, modifier, ) - } - CollectionType.MOVIES -> { + CollectionType.MOVIES -> CollectionFolderMovie( preferences, destination, modifier, ) - } - CollectionType.BOXSETS -> { - CollectionFolderGeneric( - preferences = preferences, - itemId = destination.itemId, - usePosters = true, - recursive = false, - playEnabled = false, - modifier = modifier, - sortOptions = MovieSortOptions, + CollectionType.BOXSETS -> + CollectionFolderBoxSet( + preferences, + destination.itemId, + destination.item, + false, + modifier, ) - } - CollectionType.PLAYLISTS -> { + CollectionType.PLAYLISTS -> CollectionFolderPlaylist( preferences, destination.itemId, + destination.item, true, modifier, ) - } - CollectionType.LIVETV -> { + CollectionType.LIVETV -> CollectionFolderLiveTv( preferences = preferences, destination = destination, modifier = modifier, ) - } - - CollectionType.MUSIC -> { - CollectionFolderMusic( - preferences, - destination, - modifier, - ) - } CollectionType.HOMEVIDEOS, - CollectionType.PHOTOS, - -> { - CollectionFolderPhotoAlbum( - preferences = preferences, - itemId = destination.itemId, - recursive = recursiveOverride ?: false, - modifier = modifier, - ) - } - CollectionType.MUSICVIDEOS, + CollectionType.MUSIC, CollectionType.BOOKS, - -> { + CollectionType.PHOTOS, + -> CollectionFolderGeneric( preferences, destination.itemId, @@ -520,13 +269,12 @@ fun CollectionFolder( playEnabled = true, modifier = modifier, ) - } CollectionType.FOLDERS, CollectionType.TRAILERS, CollectionType.UNKNOWN, null, - -> { + -> CollectionFolderGeneric( preferences, destination.itemId, @@ -535,6 +283,5 @@ fun CollectionFolder( playEnabled = false, 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 100b6caf..4d688666 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,12 +2,9 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateIntOffsetAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -17,26 +14,28 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection @@ -46,15 +45,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.viewModelScope @@ -63,40 +61,39 @@ import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text +import androidx.tv.material3.rememberDrawerState +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavDrawerService 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.FontAwesome -import com.github.damontecres.wholphin.ui.components.TimeDisplay -import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.TimeFormatter 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.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.setup.UserIconCardImage +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber +import org.jellyfin.sdk.model.api.DeviceProfile +import java.time.LocalTime import java.util.UUID import javax.inject.Inject @@ -104,121 +101,73 @@ import javax.inject.Inject class NavDrawerViewModel @Inject constructor( - private val api: ApiClient, - private val navDrawerService: NavDrawerService, + private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, - val setupNavigationManager: SetupNavigationManager, - val backdropService: BackdropService, - private val musicService: MusicService, ) : ViewModel() { - val serviceState = navDrawerService.state + private var all: List<NavDrawerItem>? = null + val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) + val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) + val selectedIndex = MutableLiveData(-1) + val showMore = MutableLiveData(false) - private val _state = MutableStateFlow(NavDrawerState()) - val state: StateFlow<NavDrawerState> = _state + fun init() { + viewModelScope.launchIO { + val all = all ?: navDrawerItemRepository.getNavDrawerItems() + this@NavDrawerViewModel.all = all + val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) + val moreLibraries = all.toMutableList().apply { removeAll(libraries) } - fun onClickDrawerItem( - index: Int, - item: NavDrawerItem, - ) { - when (item) { - NavDrawerItem.Favorites -> { - setIndex(index) - navigationManager.navigateToFromDrawer( - Destination.Favorites, - ) + withContext(Dispatchers.Main) { + this@NavDrawerViewModel.moreLibraries.value = moreLibraries + this@NavDrawerViewModel.libraries.value = libraries } - - NavDrawerItem.More -> { - setShowMore(!state.value.moreExpanded) - } - - NavDrawerItem.Discover -> { - setIndex(index) - navigationManager.navigateToFromDrawer( - Destination.Discover, - ) - } - - is ServerNavDrawerItem -> { - setIndex(index) - navigationManager.navigateToFromDrawer(item.destination) - } - } - } - - fun setIndex(index: Int) { - _state.update { it.copy(selectedIndex = index) } - } - - fun setShowMore(value: Boolean) { - _state.update { it.copy(moreExpanded = value) } - } - - fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) - - /** - * Determine which nav drawer item should be highlighted as currently selected - */ - fun updateSelectedIndex() { - viewModelScope.launchDefault { val asDestinations = - 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 + (libraries + listOf(NavDrawerItem.More) + moreLibraries).map { + if (it is ServerNavDrawerItem) { + it.destination + } else if (it is NavDrawerItem.Favorites) { + Destination.Favorites + } else { + null } } val backstack = navigationManager.backStack.toList().reversed() for (i in 0..<backstack.size) { val key = backstack[i] - 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 + if (key is Destination) { + val index = + if (key is Destination.Home) { + -1 + } else if (key is Destination.Search) { + -2 } else { - null + val idx = asDestinations.indexOf(key) + if (idx >= 0) { + idx + } else { + null + } } +// Timber.v("Found $index => $key") + if (index != null) { + selectedIndex.setValueOnMain(index) + break } - Timber.v("Found $index => $key") - if (index != null) { - _state.update { it.copy(selectedIndex = index) } - break } } } } - fun navigateToSetup(userList: SetupDestination) { - viewModelScope.launchDefault { - musicService.stop() - setupNavigationManager.navigateTo(userList) - } + fun setIndex(index: Int) { + selectedIndex.value = index + } + + fun setShowMore(value: Boolean) { + showMore.value = value } } -data class NavDrawerState( - val selectedIndex: Int = -1, - val moreExpanded: Boolean = false, -) - -/** - * An item that can be shown in the nav drawer - * - * Some are built-in such as Favorites. Others are created dynamically for libraries. - */ sealed interface NavDrawerItem { val id: String @@ -237,37 +186,19 @@ sealed interface NavDrawerItem { override fun name(context: Context): String = context.getString(R.string.more) } - - object Discover : NavDrawerItem { - override val id: String - get() = "a_discover" - - override fun name(context: Context): String = context.getString(R.string.discover) - } } -/** - * A server provided nav drawer item, typically a library - */ data class ServerNavDrawerItem( val itemId: UUID, val name: String, val destination: Destination, val type: CollectionType, ) : NavDrawerItem { - override val id: String = getId(itemId) + override val id: String = "s_" + itemId.toServerString() override fun name(context: Context): String = name - - companion object { - fun getId(itemId: UUID) = "s_" + itemId.toServerString() - } } -private const val HOME_INDEX = -1 -private const val SEARCH_INDEX = -2 -private const val NOW_PLAYING_INDEX = -3 - /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -277,113 +208,140 @@ fun NavDrawer( preferences: UserPreferences, user: JellyfinUser, server: JellyfinServer, - drawerState: DrawerState, - navDrawerListState: LazyListState, - onClearBackdrop: () -> Unit, + deviceProfile: DeviceProfile, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = hiltViewModel( LocalView.current.findViewTreeViewModelStoreOwner()!!, - key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either + key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { - LaunchedEffect(Unit) { viewModel.updateSelectedIndex() } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val listState = rememberLazyListState() val scope = rememberCoroutineScope() val context = LocalContext.current - val density = LocalDensity.current val focusRequester = remember { FocusRequester() } + val searchFocusRequester = remember { FocusRequester() } // If the user presses back while on the home page, open the nav drawer, another back press will quit the app BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } - val serviceState by viewModel.serviceState.collectAsState() - val state by viewModel.state.collectAsState() - val moreExpanded = state.moreExpanded - // A negative index is a built-in page, >=0 is a library - val selectedIndex = state.selectedIndex + val moreLibraries by viewModel.moreLibraries.observeAsState(listOf()) + val libraries by viewModel.libraries.observeAsState(listOf()) + LaunchedEffect(Unit) { viewModel.init() } - BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { - viewModel.setShowMore(false) + val showMore by viewModel.showMore.observeAsState(false) +// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries + // A negative index is a built in page, >=0 is a library + val selectedIndex by viewModel.selectedIndex.observeAsState(-1) + var focusedIndex by remember { mutableIntStateOf(Int.MIN_VALUE) } + val derivedFocusedIndex by remember { derivedStateOf { focusedIndex } } + + fun setShowMore(value: Boolean) { + viewModel.setShowMore(value) } - val closedDrawerWidth = CollapsedDrawerItemWidth - val openDrawerWidth = ExpandedDrawerItemWidth - val offset by animateIntOffsetAsState( - targetValue = - IntOffset( - x = - with(density) { - if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0 - }, - y = 0, - ), - animationSpec = - spring( - stiffness = DrawerAnimationStiffness, - visibilityThreshold = IntOffset.VisibilityThreshold, - ), - ) + BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + setShowMore(false) + } - ModalNavigationDrawer( + val onClick = { index: Int, item: NavDrawerItem -> + when (item) { + NavDrawerItem.Favorites -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer( + Destination.Favorites, + ) + } + + NavDrawerItem.More -> { + setShowMore(!showMore) + } + + is ServerNavDrawerItem -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer(item.destination) + } + } + } + // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 + if (false && preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) { + LaunchedEffect(derivedFocusedIndex) { + val index = derivedFocusedIndex + delay(600) + if (index != selectedIndex) { + if (index == -1) { + viewModel.setIndex(-1) + viewModel.navigationManager.goToHome() + } else if (index in libraries.indices) { + if (moreLibraries.isEmpty() || index != libraries.lastIndex) { + libraries.getOrNull(index)?.let { + onClick.invoke(index, it) + } + } + } else { + val newIndex = libraries.size - index + 1 + if (newIndex in moreLibraries.indices) { + moreLibraries.getOrNull(newIndex)?.let { + onClick.invoke(index, it) + } + } + } + } + } + } + + val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else 40.dp) + val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) + val drawerBackground by animateColorAsState( + if (drawerState.isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation( + 1.dp, + ) + } else { + MaterialTheme.colorScheme.surface + }, + ) + val spacedBy = 4.dp + NavigationDrawer( modifier = modifier, drawerState = drawerState, - drawerContent = { drawerValue -> - val isOpen = drawerValue.isOpen - val spacedBy = 4.dp - val searchFocusRequester = remember { FocusRequester() } - + drawerContent = { ProvideTextStyle(MaterialTheme.typography.labelMedium) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(spacedBy), - modifier = Modifier.fillMaxHeight(), + modifier = + Modifier + .fillMaxHeight() + .width(drawerWidth) + .background(drawerBackground), ) { // Even though some must be clicked, focusing on it should clear other focused items val interactionSource = remember { MutableInteractionSource() } - val userImageUrl = remember(user) { viewModel.getUserImage(user) } - ProfileIcon( - user = user, - imageUrl = userImageUrl, - serverName = server.name ?: server.url, - drawerOpen = isOpen, + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } + IconNavItem( + text = user?.name ?: "", + subtext = server?.name ?: server?.url, + icon = Icons.Default.AccountCircle, + selected = false, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.navigateToSetup( - SetupDestination.UserList(server), + viewModel.navigationManager.navigateToFromDrawer( + Destination.UserList( + server, + ), ) }, - modifier = Modifier, + modifier = Modifier.padding(start = drawerPadding), ) - AnimatedVisibility( - visible = serviceState.nowPlayingEnabled, - enter = expandVertically(expandFrom = Alignment.Top), - exit = shrinkVertically(shrinkTowards = Alignment.Top), - ) { - val interactionSource = remember { MutableInteractionSource() } - IconNavItem( - text = stringResource(R.string.now_playing), - subtext = serviceState.nowPlayingTitle, - icon = Icons.Default.PlayArrow, - selected = selectedIndex == NOW_PLAYING_INDEX, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.setIndex(NOW_PLAYING_INDEX) - viewModel.navigationManager.navigateTo(Destination.NowPlaying) - }, - modifier = - Modifier - .ifElse( - selectedIndex == NOW_PLAYING_INDEX, - Modifier.focusRequester(focusRequester), - ), - ) - } LazyColumn( - state = navDrawerListState, + state = listState, contentPadding = PaddingValues(0.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedByWithFooter(spacedBy), @@ -398,42 +356,46 @@ fun NavDrawer( focusRequester.tryRequestFocus() } } - }.fillMaxHeight(), + }.fillMaxHeight() + .padding(start = drawerPadding), ) { item { val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { if (focused) focusedIndex = -2 } IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, - selected = selectedIndex == SEARCH_INDEX, - drawerOpen = isOpen, + selected = selectedIndex == -2, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(SEARCH_INDEX) - viewModel.navigationManager.navigateToFromDrawer(Destination.Search()) + viewModel.setIndex(-2) + viewModel.navigationManager.navigateToFromDrawer(Destination.Search) }, modifier = Modifier .focusRequester(searchFocusRequester) .ifElse( - selectedIndex == SEARCH_INDEX, + selectedIndex == -2, Modifier.focusRequester(focusRequester), - ), + ).animateItem(), ) } item { val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { if (focused) focusedIndex = -1 } IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, - selected = selectedIndex == HOME_INDEX, - drawerOpen = isOpen, + selected = selectedIndex == -1, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.setIndex(HOME_INDEX) + viewModel.setIndex(-1) if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() - onClearBackdrop.invoke() } else { viewModel.navigationManager.goToHome() } @@ -441,71 +403,50 @@ fun NavDrawer( modifier = Modifier .ifElse( - selectedIndex == HOME_INDEX, + selectedIndex == -1, Modifier.focusRequester(focusRequester), - ), + ).animateItem(), ) } - itemsIndexed(serviceState.items) { index, it -> + itemsIndexed(libraries) { index, it -> val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { if (focused) focusedIndex = index } NavItem( library = it, selected = selectedIndex == index, - moreExpanded = moreExpanded, - drawerOpen = isOpen, + moreExpanded = showMore, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.onClickDrawerItem(index, it) + onClick.invoke(index, it) + if (it !is NavDrawerItem.More) setShowMore(false) }, modifier = Modifier .ifElse( selectedIndex == index, Modifier.focusRequester(focusRequester), - ), + ).animateItem(), ) } - if (serviceState.moreItems.isNotEmpty()) { - item { - val index = serviceState.items.size - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = NavDrawerItem.More, - selected = false, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, NavDrawerItem.More) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } - } - if (moreExpanded) { - itemsIndexed(serviceState.moreItems) { index, it -> - val adjustedIndex = - remember(serviceState) { (index + serviceState.items.size) } + if (showMore) { + itemsIndexed(moreLibraries) { index, it -> + val adjustedIndex = (index + libraries.size + 1) val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { + if (focused) focusedIndex = adjustedIndex + } NavItem( library = it, selected = selectedIndex == adjustedIndex, - moreExpanded = moreExpanded, - drawerOpen = isOpen, - onClick = { - viewModel.onClickDrawerItem( - adjustedIndex, - it, - ) - }, + moreExpanded = showMore, + drawerOpen = drawerState.isOpen, + onClick = { onClick.invoke(adjustedIndex, it) }, containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surface.copy(alpha = .5f) + if (drawerState.isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) } else { Color.Unspecified }, @@ -515,17 +456,19 @@ fun NavDrawer( .ifElse( selectedIndex == adjustedIndex, Modifier.focusRequester(focusRequester), - ), + ).animateItem(), ) } } item { val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } IconNavItem( text = stringResource(R.string.settings), icon = Icons.Default.Settings, selected = false, - drawerOpen = isOpen, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { viewModel.navigationManager.navigateTo( @@ -534,7 +477,7 @@ fun NavDrawer( ), ) }, - modifier = Modifier, + modifier = Modifier.animateItem(), ) } } @@ -549,61 +492,34 @@ fun NavDrawer( DestinationContent( destination = destination, preferences = preferences, - onClearBackdrop = onClearBackdrop, + deviceProfile = deviceProfile, modifier = Modifier - .fillMaxSize() - .offset { - offset - }.padding(start = closedDrawerWidth + 8.dp, end = 16.dp), + .fillMaxSize(), ) if (preferences.appPreferences.interfacePreferences.showClock) { - TimeDisplay() + var now by remember { mutableStateOf(LocalTime.now()) } + LaunchedEffect(Unit) { + while (isActive) { + now = LocalTime.now() + delay(1000L) + } + } + Text( + text = TimeFormatter.format(now), +// style = MaterialTheme.typography.titleMedium, + fontSize = 18.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(vertical = 16.dp, horizontal = 24.dp), + ) } } } } -@Composable -fun NavigationDrawerScope.ProfileIcon( - user: JellyfinUser, - imageUrl: String?, - serverName: String, - drawerOpen: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val focused by interactionSource.collectIsFocusedAsState() - NavigationDrawerItem( - modifier = modifier, - selected = false, - onClick = onClick, - leadingContent = { - UserIconCardImage( - id = user.id, - name = user.name, - imageUrl = imageUrl, - alpha = if (drawerOpen) 1f else .5f, - modifier = Modifier.size(DrawerIconSize), - ) - }, - supportingContent = { - Text( - text = serverName, - maxLines = 1, - ) - }, - interactionSource = interactionSource, - ) { - Text( - modifier = Modifier.enableMarquee(focused), - text = user.name ?: user.id.toString(), - maxLines = 1, - ) - } -} - @Composable fun NavigationDrawerScope.IconNavItem( text: String, @@ -626,7 +542,7 @@ fun NavigationDrawerScope.IconNavItem( icon, contentDescription = null, tint = color, - modifier = Modifier.size(DrawerIconSize), + modifier = Modifier.padding(0.dp), ) }, supportingContent = @@ -641,7 +557,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier.enableMarquee(focused), + modifier = Modifier, text = text, maxLines = 1, ) @@ -662,33 +578,21 @@ fun NavigationDrawerScope.NavItem( val context = LocalContext.current val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val icon = - remember(library) { - when (library) { - NavDrawerItem.Favorites -> { - R.string.fa_heart - } + when (library) { + NavDrawerItem.Favorites -> R.string.fa_heart + NavDrawerItem.More -> R.string.fa_ellipsis - NavDrawerItem.More -> { - R.string.fa_ellipsis + is ServerNavDrawerItem -> + when (library.type) { + CollectionType.MOVIES -> R.string.fa_film + CollectionType.TVSHOWS -> R.string.fa_tv + CollectionType.HOMEVIDEOS -> R.string.fa_video + CollectionType.LIVETV -> R.drawable.gf_dvr + CollectionType.MUSIC -> R.string.fa_music + CollectionType.BOXSETS -> R.string.fa_open_folder + CollectionType.PLAYLISTS -> R.string.fa_list_ul + else -> R.string.fa_film } - - NavDrawerItem.Discover -> { - R.string.fa_magnifying_glass_plus - } - - is ServerNavDrawerItem -> { - when (library.type) { - CollectionType.MOVIES -> R.string.fa_film - CollectionType.TVSHOWS -> R.string.fa_tv - CollectionType.HOMEVIDEOS -> R.string.fa_video - CollectionType.LIVETV -> R.drawable.gf_dvr - CollectionType.MUSIC -> R.string.fa_music - CollectionType.BOXSETS -> R.string.fa_open_folder - CollectionType.PLAYLISTS -> R.string.fa_list_ul - else -> R.string.fa_film - } - } - } } val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( @@ -716,26 +620,23 @@ fun NavigationDrawerScope.NavItem( painter = painterResource(icon), contentDescription = null, tint = color, - modifier = Modifier.size(DrawerIconSize), + modifier = Modifier, ) } } }, - trailingContent = + trailingContent = { if (library is NavDrawerItem.More) { - { - Icon( - imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, - contentDescription = null, - ) - } - } else { - null - }, + Icon( + imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, + contentDescription = null, + ) + } + }, interactionSource = interactionSource, ) { Text( - modifier = Modifier.enableMarquee(focused), + modifier = Modifier, text = library.name(context), maxLines = 1, ) @@ -743,7 +644,6 @@ fun NavigationDrawerScope.NavItem( } @Composable -@ReadOnlyComposable fun navItemColor( selected: Boolean, focused: Boolean, @@ -767,38 +667,25 @@ fun navItemColor( else -> .2f } return when { - selected && focused -> { + selected && focused -> when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, - AppThemeColors.RED, - AppThemeColors.BROWN, -> MaterialTheme.colorScheme.border AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, -> MaterialTheme.colorScheme.primary } - } - selected -> { - MaterialTheme.colorScheme.border - } - - focused -> { - LocalContentColor.current - } - - else -> { - MaterialTheme.colorScheme.onSurface - } + selected -> MaterialTheme.colorScheme.border + focused -> LocalContentColor.current + else -> MaterialTheme.colorScheme.onSurface }.copy(alpha = alpha) } } -val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen - -val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open +val DrawerState.isOpen: Boolean get() = this.currentValue == DrawerValue.Open diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt deleted file mode 100644 index 0cca40c8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * This file contains code copied and modified from: - * https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-androidx-tv-material-release/tv/tv-material/src/main/java/androidx/tv/material3/ - * - * Their license & required attribution is below. - * - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.github.damontecres.wholphin.ui.nav - -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.tv.material3.DrawerState -import androidx.tv.material3.DrawerValue -import androidx.tv.material3.ListItem -import androidx.tv.material3.ListItemDefaults -import androidx.tv.material3.NavigationDrawerItemColors -import androidx.tv.material3.NavigationDrawerItemDefaults -import androidx.tv.material3.NavigationDrawerScope -import androidx.tv.material3.rememberDrawerState - -/** - * This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer]. - * - * It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation. - */ -@Composable -fun ModalNavigationDrawer( - drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, - modifier: Modifier = Modifier, - drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), - content: @Composable () -> Unit, -) { - Box(modifier = modifier) { - DrawerSheet( - modifier = - Modifier - .align(Alignment.CenterStart), - drawerState = drawerState, - content = drawerContent, - ) - - content() - } -} - -@Composable -private fun DrawerSheet( - modifier: Modifier = Modifier, - drawerState: DrawerState = remember { DrawerState() }, - content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, -) { - // indicates that the drawer has been set to its initial state and has grabbed focus if - // necessary. Controls whether focus is used to decide the state of the drawer going forward. - var initializationComplete: Boolean by remember { mutableStateOf(false) } - var focusState by remember { mutableStateOf<FocusState?>(null) } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(key1 = drawerState.currentValue) { - if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) { - // used to grab focus if the drawer state is set to Open on start. - focusRequester.requestFocus() - } - initializationComplete = true - } - - val internalModifier = - Modifier - .focusRequester(focusRequester) - .animateContentSize( - animationSpec = - spring( - stiffness = DrawerAnimationStiffness, - visibilityThreshold = IntSize.VisibilityThreshold, - ), - ).fillMaxHeight() - // adding passed-in modifier here to ensure animateContentSize is called before other - // size based modifiers. - .then(modifier) - .onFocusChanged { - focusState = it - - if (initializationComplete) { - drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed) - } - }.focusGroup() - - Box(modifier = internalModifier) { - NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply { - content(drawerState.currentValue) - } - } -} - -internal class NavigationDrawerScopeImpl( - override val hasFocus: Boolean, -) : NavigationDrawerScope - -internal val CollapsedDrawerItemWidth = 64.dp -internal val ExpandedDrawerItemWidth = 224.dp -internal val DrawerIconSize = 24.dp -internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2 -internal val DrawerAnimationStiffness = Spring.StiffnessMedium - -@Composable -internal fun NavigationDrawerScope.NavigationDrawerItem( - selected: Boolean, - onClick: () -> Unit, - leadingContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onLongClick: (() -> Unit)? = null, - supportingContent: (@Composable () -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null, - tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation, - colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(), - interactionSource: MutableInteractionSource? = null, - content: @Composable () -> Unit, -) { - val animatedWidth by - animateDpAsState( - targetValue = - if (hasFocus) { - ExpandedDrawerItemWidth - } else { - CollapsedDrawerItemWidth - }, - label = "NavigationDrawerItem width open/closed state of the drawer item", - ) - val navDrawerItemHeight = - if (supportingContent == null) { - NavigationDrawerItemDefaults.ContainerHeightOneLine - } else { - NavigationDrawerItemDefaults.ContainerHeightTwoLine - } - - ListItem( - selected = selected, - onClick = onClick, - headlineContent = content, - leadingContent = { - Box( - Modifier - .padding(horizontal = DrawerIconPadding) - .size(DrawerIconSize), - ) { leadingContent() } - }, - trailingContent = trailingContent, - supportingContent = supportingContent, - modifier = - modifier - .layout { measurable, constraints -> - val width = animatedWidth.roundToPx() - val height = navDrawerItemHeight.roundToPx() - val placeable = - measurable.measure( - constraints.copy( - minWidth = width, - maxWidth = width, - minHeight = height, - maxHeight = height, - ), - ) - layout(placeable.width, placeable.height) { placeable.place(0, 0) } - }, - enabled = enabled, - onLongClick = onLongClick, - tonalElevation = tonalElevation, - colors = colors.toToggleableListItemColors(hasFocus), - scale = ListItemDefaults.scale(1f, 1f), - interactionSource = interactionSource, - ) -} - -@Composable -private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) = - ListItemDefaults.colors( - containerColor = containerColor, - contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor, - focusedContainerColor = focusedContainerColor, - focusedContentColor = focusedContentColor, - pressedContainerColor = pressedContainerColor, - pressedContentColor = pressedContentColor, - selectedContainerColor = selectedContainerColor, - selectedContentColor = selectedContentColor, - disabledContainerColor = disabledContainerColor, - disabledContentColor = - if (doesNavigationDrawerHaveFocus) { - disabledContentColor - } else { - disabledInactiveContentColor - }, - focusedSelectedContainerColor = focusedSelectedContainerColor, - focusedSelectedContentColor = focusedSelectedContentColor, - pressedSelectedContainerColor = pressedSelectedContainerColor, - pressedSelectedContentColor = pressedSelectedContentColor, - ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt new file mode 100644 index 00000000..7fc97366 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.Player +import androidx.media3.common.Player.Listener +import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.keepScreenOn + +/** + * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback + * + * This will clean up the listener when disposed + */ +@Composable +fun AmbientPlayerListener(player: Player) { + val context = LocalContext.current + DisposableEffect(player) { + val listener = + object : Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + context.findActivity()?.keepScreenOn(isPlaying) + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + context.findActivity()?.keepScreenOn(false) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 380901a1..0fe5c564 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -1,83 +1,15 @@ package com.github.damontecres.wholphin.ui.playback -import androidx.core.net.toUri -import androidx.media3.common.MediaMetadata -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import 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 -/** - * Metadata about the currently playing media - * - * @see CurrentPlayback - */ data class CurrentMediaInfo( - val sourceId: String?, - val videoStream: SimpleVideoStream?, - val audioStreams: List<SimpleMediaStream>, - val subtitleStreams: List<SimpleMediaStream>, + val audioStreams: List<AudioStream>, + val subtitleStreams: List<SubtitleStream>, val chapters: List<Chapter>, val trickPlayInfo: TrickplayInfo?, ) { companion object { - val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) + val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) } } - -/** - * 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() - .setTitle(title) - .setSubtitle(subtitle) - .setReleaseYear(data.productionYear) - .setDescription(data.overview) - .setArtworkUri(imageUrl?.toUri()) - .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds) - .setMediaType( - when (type) { - BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE - BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW - BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO - BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL - else -> MediaMetadata.MEDIA_TYPE_VIDEO - }, - ).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentPlayback.kt index 54663af6..3295c686 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,21 +1,12 @@ 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 -/** - * Information about how the current media is being played such transcoding and decoder info - * - * @see CurrentMediaInfo - */ data class CurrentPlayback( val item: BaseItem, val tracks: List<TrackSupport>, @@ -27,13 +18,4 @@ data class CurrentPlayback( val videoDecoder: String? = null, 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 2ec02410..557dbcce 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,27 +1,34 @@ 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.width +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.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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -29,71 +36,80 @@ 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.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.RemoteSubtitleInfo @Composable fun DownloadSubtitlesContent( - state: SubtitleSearchStatus, + state: SubtitleSearch, language: String, onSearch: (String) -> Unit, onClickDownload: (RemoteSubtitleInfo) -> Unit, + onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { when (val s = state) { - SubtitleSearchStatus.Inactive -> {} - - SubtitleSearchStatus.Searching -> { - Text( - text = stringResource(R.string.searching), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = modifier, - ) + SubtitleSearch.Searching -> { + Wrapper { + Text( + text = stringResource(R.string.searching), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } } - SubtitleSearchStatus.Downloading -> { - Text( - text = stringResource(R.string.downloading), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = modifier, - ) + SubtitleSearch.Downloading -> { + Wrapper { + Text( + text = stringResource(R.string.downloading), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } } - is SubtitleSearchStatus.Error -> { - ErrorMessage(null, s.ex, modifier) - } + is SubtitleSearch.Error -> Wrapper { ErrorMessage(null, s.ex, modifier) } - is SubtitleSearchStatus.Success -> { + is SubtitleSearch.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, + modifier = + modifier + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), ) { Text( - text = stringResource(R.string.search_and_download_subtitles), + text = "Search & download subtitles", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) -// val lang = rememberTextFieldState(language) - var lang by rememberSaveable { mutableStateOf(language) } + var lang by remember { mutableStateOf(language) } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.language), + text = "Language", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -112,29 +128,39 @@ fun DownloadSubtitlesContent( ), ) } - if (s.options.isEmpty()) { + if (dialogItems.isEmpty()) { Text( - text = stringResource(R.string.no_subtitles_found), + text = "No remote subtitles were 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(s.options) { index, item -> - SubtitleInfo( - subtitle = item, - onClick = onClickDownload, - modifier = - Modifier - .focusRequester(focusRequesters[index]), - ) + 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), + ), + ) + } } } } @@ -144,56 +170,54 @@ fun DownloadSubtitlesContent( } @Composable -fun SubtitleInfo( - subtitle: RemoteSubtitleInfo, +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>, onClick: (RemoteSubtitleInfo) -> Unit, - modifier: Modifier = Modifier, -) { - ListItem( - selected = false, - onClick = { onClick.invoke(subtitle) }, +) = options.map { op -> + DialogItem( + onClick = { onClick.invoke(op) }, headlineContent = { Text( - text = subtitle.name ?: "", + text = op.name ?: "", ) }, - overlineContent = - if (subtitle.isHashMatch == true) { - { - Text(stringResource(R.string.hash_matches)) - } - } else { - null - }, supportingContent = { - 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(" - ") + 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), + ), + ) } Text( - text = details, + text = strings.joinToString(" - "), ) }, trailingContent = { - subtitle.communityRating?.let { rating -> + op.communityRating?.let { rating -> Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -209,43 +233,5 @@ fun SubtitleInfo( } } }, - 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/KeyIdentifier.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt index eed379e7..1915e02b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/KeyIdentifier.kt @@ -32,14 +32,6 @@ fun isControllerMedia(event: KeyEvent) = event.key == Key.ButtonL1 || event.key == Key.ButtonL2 -fun isDpadLeft(event: KeyEvent) = - event.key == Key.DirectionLeft || - event.key == Key.SystemNavigationLeft - -fun isDpadRight(event: KeyEvent) = - event.key == Key.DirectionRight || - event.key == Key.SystemNavigationRight - fun isSkipBack(event: KeyEvent) = event.key == Key.DirectionLeft || event.key == Key.ButtonL1 || diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt deleted file mode 100644 index 30165f58..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.annotation.OptIn -import androidx.media3.common.ForwardingSimpleBasePlayer -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.preferences.PlaybackPreferences -import com.github.damontecres.wholphin.preferences.skipBackOnResume -import com.github.damontecres.wholphin.ui.seekBack -import com.google.common.util.concurrent.ListenableFuture -import timber.log.Timber - -@OptIn(UnstableApi::class) -class MediaSessionPlayer( - player: Player, - private val playbackPreferences: PlaybackPreferences, -) : ForwardingSimpleBasePlayer(player) { - override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { - Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") - if (playWhenReady) { - playbackPreferences.skipBackOnResume?.let { - player.seekBack(it) - } - } - return super.handleSetPlayWhenReady(playWhenReady) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt new file mode 100644 index 00000000..728f283a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt @@ -0,0 +1,73 @@ +package com.github.damontecres.wholphin.ui.playback + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.MediaStream + +data class SubtitleStream( + val index: Int, + val language: String?, + val title: String?, + val codec: String?, + val codecTag: String?, + val external: Boolean, + val forced: Boolean, + val default: Boolean, + val displayTitle: String?, +) { + val displayName: String + get() = + displayTitle ?: listOfNotNull( + language, + title, + codec, + ).joinToString(" - ") + .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } + + companion object { + fun from(it: MediaStream): SubtitleStream = + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + it.displayTitle, + ) + } +} + +data class AudioStream( + val index: Int, + val language: String?, + val title: String?, + val codec: String?, + val codecTag: String?, + val channels: Int?, + val channelLayout: String?, +) { + val displayName: String + get() = + listOfNotNull( + language, + title, + codec, + channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" }, + ).joinToString(" - ").ifBlank { "Unknown" } + + companion object { + fun from(it: MediaStream): AudioStream = + AudioStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.channels, + it.channelLayout, + ) + } +} 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 d93ffda6..44048a40 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,8 +10,10 @@ 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 @@ -19,7 +21,6 @@ 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 @@ -28,10 +29,7 @@ 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 @@ -49,17 +47,10 @@ 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 @@ -73,7 +64,6 @@ fun NextUpEpisode( timeLeft: Duration?, modifier: Modifier = Modifier, aspectRatio: Float = AspectRatios.WIDE, - runtime: Duration? = null, ) { val interactionSource = remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } @@ -108,7 +98,7 @@ fun NextUpEpisode( modifier = Modifier .fillMaxHeight() - .aspectRatio(aspectRatio.coerceAtLeast(AspectRatios.MIN)) + .aspectRatio(aspectRatio) // .fillMaxSize() // .weight(1f) .focusRequester(focusRequester), @@ -132,36 +122,9 @@ 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), - ) - } } } } @@ -226,41 +189,34 @@ fun NextUpCard( @Composable private fun NextUpEpisodePreview() { WholphinTheme(true) { - val theme = MaterialTheme.colorScheme val previewHandler = AsyncImagePreviewHandler { - 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), - ), - ) + ColorImage(Color.Blue.toArgb()) } + + 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 deleted file mode 100644 index b17036b8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ /dev/null @@ -1,451 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import android.app.Activity -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ItemPlaybackDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.PlaylistItem -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.PlaylistCreationResult -import com.github.damontecres.wholphin.services.PlaylistCreator -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.LoadingState -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.playStateApi -import org.jellyfin.sdk.api.client.extensions.subtitleApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.videosApi -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.PlayMethod -import org.jellyfin.sdk.model.api.PlaybackOrder -import org.jellyfin.sdk.model.api.PlaybackStartInfo -import org.jellyfin.sdk.model.api.PlaybackStopInfo -import org.jellyfin.sdk.model.api.RepeatMode -import org.jellyfin.sdk.model.extensions.inWholeTicks -import org.jellyfin.sdk.model.extensions.ticks -import timber.log.Timber -import java.io.File -import java.util.UUID -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -@HiltViewModel -class PlayExternalViewModel - @Inject - constructor( - private val savedStateHandle: SavedStateHandle, - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val itemPlaybackDao: ItemPlaybackDao, - private val playlistCreator: PlaylistCreator, - private val streamChoiceService: StreamChoiceService, - private val navigationManager: NavigationManager, - private val userPreferencesService: UserPreferencesService, - ) : ViewModel() { - val launched = savedStateHandle.getMutableStateFlow("launched", false) - val state = MutableStateFlow(PlayExternalState()) - - fun init(destination: Destination) { - Timber.v("init called: %s", destination) - state.update { it.copy(loading = LoadingState.Loading) } - viewModelScope.launchDefault { - val prefs = userPreferencesService.getCurrent() - val positionMs: Long - val itemId = - when (val d = destination) { - is Destination.Playback -> { - positionMs = d.positionMs - d.itemId - } - - is Destination.PlaybackList -> { - positionMs = 0 - d.itemId - } - - else -> { - throw IllegalArgumentException("Destination not supported: $destination") - } - } - try { - val queriedItem = api.userLibraryApi.getItem(itemId).content - val playlistItem = - if (queriedItem.type.playable) { - PlaylistItem.Media(BaseItem(queriedItem)) - } else if (destination is Destination.PlaybackList) { - val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) - when (val r = playlistResult) { - is PlaylistCreationResult.Error -> { - state.update { - it.copy( - loading = LoadingState.Error(r.message, r.ex), - ) - } - return@launchDefault - } - - is PlaylistCreationResult.Success -> { - if (r.playlist.items.isEmpty()) { - showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) - navigationManager.goBack() - return@launchDefault - } - r.playlist.items.first() - } - } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") - } - val playbackConfig = - serverRepository.currentUser?.let { user -> - itemPlaybackDao.getItem(user, playlistItem.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId != null) { - it - } else { - null - } - } - } - val item = - when (playlistItem) { - is PlaylistItem.Intro -> playlistItem.item - is PlaylistItem.Media -> playlistItem.item - } - val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val subtitleStc = - streamChoiceService.getSeriesTrackChoices( - item.data, - SeriesTrackChoiceType.SUBTITLE, - ) - if (mediaSource == null) { - Timber.w("Media source is null") - return@launchDefault - } - savedStateHandle[KEY_ID] = playlistItem.id - savedStateHandle[KEY_MEDIA_ID] = mediaSource.id - val subtitleIndex = - streamChoiceService - .chooseSubtitleStream( - item = item.data, - source = mediaSource, - audioStream = null, - itemPlayback = playbackConfig, - stc = subtitleStc, - prefs = prefs, - )?.stream - ?.index - val externalSubtitles = - mediaSource.mediaStreams - ?.filter { it.isExternal } - ?.sortedWith(compareBy<MediaStream> { it.index == subtitleIndex }.thenBy { it.isDefault }) - .orEmpty() - val subtitleUrls = - externalSubtitles.map { - val format = it.path?.let { File(it).extension } ?: "srt" - api.subtitleApi - .getSubtitleUrl( - routeItemId = itemId, - routeMediaSourceId = mediaSource.id!!, - routeIndex = it.index, - routeFormat = format, - ).toUri() - } - - val uri = - api.videosApi - .getVideoStreamUrl( - itemId = item.id, - mediaSourceId = mediaSource.id, - static = true, - ).toUri() - val playerId = prefs.appPreferences.playbackPreferences.externalPlayer - // Make sure player is available, user could have uninstalled it - val foundPlayer = - getExternalPlayers(context).firstOrNull { it.identifier == playerId } != null - val component = - if (playerId.isNotNullOrBlank() && foundPlayer) { - ComponentName.unflattenFromString(playerId) - } else { - null - } - Timber.v("playerId=%s, component=%s", playerId, component) - val title = "${item.title} ${item.subtitleLong}" - val intent = - Intent(Intent.ACTION_VIEW).apply { - setComponent(component) - setDataAndTypeAndNormalize(uri, "video/*") - putExtra("title", title) - putExtra("position", positionMs.toInt()) - - // MX/mpv - putExtra("return_result", true) - putExtra("secure_uri", true) - putExtra("subs", subtitleUrls.toTypedArray()) - putExtra( - "subs.name", - externalSubtitles - .map { it.displayTitle ?: it.index.toString() } - .toTypedArray(), - ) - if (subtitleIndex != null) { - externalSubtitles - .indexOfFirstOrNull { it.index == subtitleIndex } - ?.let { - putExtra("subs.enable", arrayOf(subtitleUrls[it])) - } - } - - // VLC - // A zero position alone does not override VLC's saved resume position. - putExtra("from_start", positionMs == 0L) - if (subtitleUrls.isNotEmpty()) { - putExtra("subtitles_location", subtitleUrls.first().toString()) - } - mediaSource.runTimeTicks?.ticks?.inWholeMilliseconds?.let { - putExtra("extra_duration", it) - } - - // Vimu - https://vimu.tv/player-api/ - putExtra("startfrom", positionMs.toInt()) - putExtra("forceresume", false) - putExtra("forcename", title) - externalSubtitles - .indexOfFirstOrNull { it.index == subtitleIndex && it.codec == "srt" } - ?.let { - putExtra("forcedsrt", subtitleUrls[it]) - } - } - api.playStateApi.reportPlaybackStart( - PlaybackStartInfo( - canSeek = false, - itemId = itemId, - isPaused = false, - playMethod = PlayMethod.DIRECT_PLAY, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - isMuted = false, - ), - ) - state.update { - PlayExternalState( - loading = LoadingState.Success, - intent = intent, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error for destination %s", destination) - state.update { - it.copy(loading = LoadingState.Error(ex)) - } - } - } - } - - fun onResult(result: ActivityResult) { - viewModelScope.launchDefault { - val itemId = savedStateHandle.get<UUID?>(KEY_ID) - try { - val mediaSourceId = savedStateHandle.get<String?>(KEY_MEDIA_ID) - if (itemId == null) { - Timber.w("itemId is null") - return@launchDefault - } - Timber.v( - "Result: result=%s, action=%s, itemId=%s", - result.resultCode, - result.data?.action, - itemId, - ) - if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || - // Vimu return 1 for video completion - (result.data?.action == "net.gtvbox.videoplayer.result" && result.resultCode == 1) - ) { - val position: Long? - val data = result.data - when (data?.action) { - // VLC: https://wiki.videolan.org/Android_Player_Intents/ - "org.videolan.vlc.player.result" -> { - position = - data - .getLongExtra("extra_position", Long.MIN_VALUE) - .takeIf { it > 0 } - } - - // mpv-android: https://mpv-android.github.io/mpv-android/intent.html - "is.xyz.mpv.MPVActivity.result", - // MX player: https://mx.j2inter.com/api - "com.mxtech.intent.result.VIEW", - // VIMU: https://vimu.tv/player-api/ - "net.gtvbox.videoplayer.result", - -> { - position = - data - .getIntExtra("position", Int.MIN_VALUE) - .toLong() - .takeIf { it >= 0 } - } - - else -> { - // Unsupported app - val posInt = - data - ?.getIntExtra("position", Int.MIN_VALUE) - ?.takeIf { it >= 0 } - ?.toLong() - position = - posInt ?: data - ?.getLongExtra("position", -1L) - ?.takeIf { it >= 0 } - } - } - 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?.takeIf { it >= 0 }, - failed = false, - ), - ) - } - } else { - Timber.w( - "Activity result: %s, action=%s", - result.resultCode, - result.data?.action, - ) - showToast(context, "Unknown result from external player") - } - navigationManager.goBack() - state.update { PlayExternalState() } - launched.update { false } - } catch (_: CancellationException) { - } catch (ex: Exception) { - Timber.e(ex, "Error during external playback of %s", itemId) - state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun reportException(ex: Exception) { - Timber.e(ex, "Error launching activity") - state.update { it.copy(loading = LoadingState.Error(ex)) } - } - - companion object { - private const val KEY_ID = "itemId" - private const val KEY_MEDIA_ID = "mediaId" - } - } - -data class PlayExternalState( - val loading: LoadingState = LoadingState.Pending, - val intent: Intent = Intent(), -) - -@Composable -fun PlayExternalPage( - preferences: UserPreferences, - destination: Destination, - modifier: Modifier = Modifier, - viewModel: PlayExternalViewModel = - hiltViewModel( - viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity, - ), -) { - val launcher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - onResult = viewModel::onResult, - ) - - val state by viewModel.state.collectAsState() - val launched by viewModel.launched.collectAsState() - LaunchedEffect(Unit) { - if (!launched) { - viewModel.init(destination) - } - } - - when (val l = state.loading) { - LoadingState.Pending -> { - LoadingPage(modifier, false) - } - - LoadingState.Loading, - -> { - LoadingPage(modifier) - } - - is LoadingState.Error -> { - ErrorMessage(l, modifier) - } - - LoadingState.Success -> { - LoadingPage(modifier) - if (!launched) { - LifecycleStartEffect(Unit) { - Timber.i("Launching external playback") - viewModel.launched.update { true } - try { - launcher.launch(state.intent) - } catch (ex: Exception) { - viewModel.reportException(ex) - } - onStopOrDispose { } - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 474c7932..b801e8ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,22 +1,20 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") val playbackScaleOptions = mapOf( - ContentScale.Fit to R.string.content_scale_fit, - ContentScale.None to R.string.none, - ContentScale.Crop to R.string.content_scale_crop, + ContentScale.Fit to "Fit", + ContentScale.None to "None", + ContentScale.Crop to "Crop", // ContentScale.Inside to "Inside", - ContentScale.FillBounds to R.string.content_scale_fill, - ContentScale.FillWidth to R.string.content_scale_fill_width, - ContentScale.FillHeight to R.string.content_scale_fill_height, + ContentScale.FillBounds to "Fill", + ContentScale.FillWidth to "Fill Width", + ContentScale.FillHeight to "Fill Height", ) val PrefContentScale.scale: ContentScale @@ -44,15 +42,15 @@ val BaseItemKind.playable: Boolean BaseItemKind.MUSIC_VIDEO, BaseItemKind.TRAILER, BaseItemKind.VIDEO, + -> true + + // TODO add support for these eventually BaseItemKind.LIVE_TV_CHANNEL, BaseItemKind.LIVE_TV_PROGRAM, BaseItemKind.PROGRAM, BaseItemKind.RECORDING, BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, - -> true - - // TODO add support for these eventually BaseItemKind.AUDIO_BOOK, BaseItemKind.AUDIO, BaseItemKind.CHANNEL, @@ -83,20 +81,3 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } - -fun getTypeFor(collectionType: CollectionType): BaseItemKind? = - when (collectionType) { - CollectionType.UNKNOWN -> null - CollectionType.MOVIES -> BaseItemKind.MOVIE - CollectionType.TVSHOWS -> BaseItemKind.SERIES - CollectionType.MUSIC -> BaseItemKind.AUDIO - CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO - CollectionType.TRAILERS -> BaseItemKind.TRAILER - CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO - CollectionType.BOXSETS -> BaseItemKind.BOX_SET - CollectionType.BOOKS -> BaseItemKind.BOOK - CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM - CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL - CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST - CollectionType.FOLDERS -> BaseItemKind.FOLDER - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt similarity index 58% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 45fcdd4c..8e17d2a7 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/PlaybackControls.kt @@ -1,14 +1,14 @@ @file:kotlin.OptIn(ExperimentalMaterial3Api::class) -package com.github.damontecres.wholphin.ui.playback.overlay +package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.annotation.DrawableRes import androidx.annotation.OptIn -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,13 +22,12 @@ 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.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape 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 @@ -38,50 +37,35 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip 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.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 -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.FontAwesome -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 import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.skipStringRes import com.github.damontecres.wholphin.ui.theme.LocalTheme -import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay @@ -92,9 +76,6 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -/** - * Possible actions the user can take during playback - */ sealed interface PlaybackAction { data object ShowDebug : PlaybackAction @@ -123,22 +104,18 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction - - data class ChangeBitrate( - val maxBitrate: Long, - ) : PlaybackAction } -/** - * UI for actual playback controls such as seek bar, play/pause and seek buttons - */ @OptIn(UnstableApi::class) @Composable fun PlaybackControls( - player: Player, + playerControls: Player, controllerViewState: ControllerViewState, onPlaybackActionClick: (PlaybackAction) -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + moreFocusRequester: FocusRequester, + captionFocusRequester: FocusRequester, + settingsFocusRequester: FocusRequester, onSeekProgress: (Long) -> Unit, showPlay: Boolean, previousEnabled: Boolean, @@ -173,7 +150,7 @@ fun PlaybackControls( verticalArrangement = Arrangement.spacedBy(0.dp), ) { SeekBar( - player = player, + player = playerControls, controllerViewState = controllerViewState, onSeekProgress = onSeekProgress, interactionSource = seekBarInteractionSource, @@ -193,13 +170,13 @@ fun PlaybackControls( .fillMaxWidth(), ) { LeftPlaybackButtons( + moreFocusRequester = moreFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier.align(Alignment.CenterStart), ) - PlaybackButtons( - player = player, + player = playerControls, initialFocusRequester = initialFocusRequester, onControllerInteraction = onControllerInteraction, onPlaybackActionClick = onPlaybackActionClick, @@ -211,23 +188,27 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) - Row( modifier = Modifier.align(Alignment.CenterEnd), ) { currentSegment?.let { segment -> - TextButton( - stringRes = segment.type.skipStringRes, + Button( onClick = { - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) }, modifier = Modifier .align(Alignment.CenterVertically) .padding(end = 32.dp), - ) + ) { + Text( + text = stringResource(segment.type.skipStringRes), + ) + } } RightPlaybackButtons( + captionFocusRequester = captionFocusRequester, + settingsFocusRequester = settingsFocusRequester, onControllerInteraction = onControllerInteraction, onClickPlaybackDialogType = onClickPlaybackDialogType, modifier = Modifier, @@ -280,61 +261,36 @@ fun SeekBar( seekBack = seekBack, seekForward = seekForward, ) - 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, + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - 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), - ) - } + 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 buttonSpacing = 12.dp +private val buttonSpacing = 4.dp @Composable fun LeftPlaybackButtons( + moreFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -343,21 +299,24 @@ fun LeftPlaybackButtons( modifier = modifier.focusGroup(), horizontalArrangement = Arrangement.spacedBy(buttonSpacing), ) { + // More options PlaybackButton( - iconRes = R.drawable.vector_settings, + iconRes = R.drawable.baseline_more_vert_96, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) + onClickPlaybackDialogType.invoke(PlaybackDialogType.MORE) }, enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier, + modifier = Modifier.focusRequester(moreFocusRequester), ) } } @Composable fun RightPlaybackButtons( + captionFocusRequester: FocusRequester, + settingsFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, modifier: Modifier = Modifier, @@ -375,18 +334,18 @@ fun RightPlaybackButtons( onClickPlaybackDialogType.invoke(PlaybackDialogType.CAPTIONS) }, onControllerInteraction = onControllerInteraction, - modifier = Modifier, + modifier = Modifier.focusRequester(captionFocusRequester), ) - // Audio - PlaybackFaButton( - enabled = true, - iconRes = R.string.fa_volume_high, + // Playback speed, etc + PlaybackButton( + iconRes = R.drawable.vector_settings, onClick = { onControllerInteraction.invoke() - onClickPlaybackDialogType.invoke(PlaybackDialogType.AUDIO) + onClickPlaybackDialogType.invoke(PlaybackDialogType.SETTINGS) }, + enabled = true, onControllerInteraction = onControllerInteraction, - modifier = Modifier, + modifier = Modifier.focusRequester(settingsFocusRequester), ) } } @@ -406,62 +365,60 @@ fun PlaybackButtons( seekForward: Duration, modifier: Modifier = Modifier, ) { - 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() + 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) } - }, - 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, - ) - } + } else { + player.pause() + } + }, + 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, + ) } } @@ -478,9 +435,9 @@ fun PlaybackButton( Button( enabled = enabled, onClick = onClick, -// shape = ButtonDefaults.shape(CircleShape), + shape = ButtonDefaults.shape(CircleShape), colors = - ClickableSurfaceDefaults.colors( + ButtonDefaults.colors( containerColor = AppColors.TransparentBlack25, focusedContainerColor = selectedColor, ), @@ -488,7 +445,8 @@ fun PlaybackButton( interactionSource = interactionSource, modifier = modifier - .size(36.dp, 36.dp) + .padding(4.dp) + .size(44.dp, 44.dp) .onFocusChanged { onControllerInteraction.invoke() }, ) { Icon( @@ -506,81 +464,13 @@ fun PlaybackButton( } @Composable -fun PlaybackFaButton( - @StringRes iconRes: Int, - onClick: () -> Unit, - onControllerInteraction: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - textColor: Color = Color.Unspecified, -) { - val selectedColor = MaterialTheme.colorScheme.border - Button( - enabled = enabled, - onClick = onClick, -// shape = ButtonDefaults.shape(CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = AppColors.TransparentBlack25, - focusedContainerColor = selectedColor, - ), - contentPadding = PaddingValues(4.dp), - interactionSource = interactionSource, - modifier = - modifier - .size(36.dp, 36.dp) - .onFocusChanged { onControllerInteraction.invoke() }, - ) { - Text( - text = stringResource(iconRes), - fontSize = 18.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - color = - if (textColor.isSpecified) { - textColor - } else if (LocalTheme.current == AppThemeColors.OLED_BLACK) { - LocalContentColor.current - } else { - MaterialTheme.colorScheme.onSurface - }, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.CenterVertically), - ) - } -} - -/** - * Show a dialog aligned to the bottom of the screen instead of centered. - * - * @param gravity The [Gravity] to align the dialog to left or right - */ -@Composable -fun <T> BottomDialog( - choices: List<BottomDialogItem<T>>, +fun BottomDialog( + choices: List<String>, onDismissRequest: () -> Unit, - onSelectChoice: (Int, BottomDialogItem<T>) -> Unit, + onSelectChoice: (Int, String) -> Unit, gravity: Int, - currentChoice: BottomDialogItem<T>? = null, + currentChoice: Int? = null, ) { - val initialIndex = - remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0 - val listState = - rememberLazyListState( - initialFirstVisibleItemIndex = initialIndex, - ) - val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } - val bringIntoViewRequesters = - remember(choices.size) { List(choices.size) { BringIntoViewRequester() } } - if (currentChoice != null) { - LaunchedEffect(Unit) { - bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() - focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() - } - } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -597,13 +487,9 @@ fun <T> BottomDialog( Modifier .wrapContentSize() .padding(8.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(8.dp), - ), + .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), ) { LazyColumn( - state = listState, modifier = Modifier .fillMaxWidth() @@ -614,33 +500,39 @@ fun <T> BottomDialog( ) { itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + val color = + if (focused) { + MaterialTheme.colorScheme.inverseOnSurface + } else { + MaterialTheme.colorScheme.onSurface + } ListItem( - selected = choice == currentChoice, - enabled = choice.enabled, + selected = index == currentChoice, onClick = { onDismissRequest() onSelectChoice(index, choice) }, leadingContent = { - SelectedLeadingContent(choice == currentChoice) - }, - headlineContent = { - Text( - text = choice.headline, - ) - }, - supportingContent = { - choice.supporting?.let { - Text( - text = it, + if (index == currentChoice) { + Box( + modifier = + Modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .align(Alignment.Center) + .background(color) + .size(8.dp), ) } }, + headlineContent = { + Text( + text = choice, + color = color, + ) + }, interactionSource = interactionSource, - modifier = - Modifier - .bringIntoViewRequester(bringIntoViewRequesters[index]) - .focusRequester(focusRequesters[index]), ) } } @@ -648,29 +540,6 @@ fun <T> BottomDialog( } } -data class BottomDialogItem<T>( - val data: T, - val headline: String, - val supporting: String?, - val enabled: Boolean = true, +data class MoreButtonOptions( + val options: Map<String, PlaybackAction>, ) - -@PreviewTvSpec -@Composable -private fun ButtonPreview() { - WholphinTheme { - Row(Modifier.background(Color.Red)) { - PlaybackButton( - iconRes = R.drawable.baseline_play_arrow_24, - onClick = {}, - onControllerInteraction = {}, - ) - PlaybackFaButton( - iconRes = R.string.fa_volume_high, - onClick = {}, - onControllerInteraction = {}, - textColor = Color.Green, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt new file mode 100644 index 00000000..496166c3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.byteRateSuffixes +import com.github.damontecres.wholphin.ui.formatBytes +import com.github.damontecres.wholphin.ui.letNotEmpty +import org.jellyfin.sdk.model.api.TranscodingInfo + +@Composable +fun PlaybackDebugOverlay( + currentPlayback: CurrentPlayback?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + ) { + ProvideTextStyle(MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface)) { + SimpleTable( + buildList { + add("Backend:" to currentPlayback?.backend?.toString()) + add("Play method:" to currentPlayback?.playMethod?.serialName) + if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) { + add("Video Decoder:" to currentPlayback.videoDecoder) + add("Audio Decoder:" to currentPlayback.audioDecoder) + } + }, + ) + currentPlayback?.transcodeInfo?.let { + TranscodeInfo(it, Modifier) + } + } + } + currentPlayback?.tracks?.letNotEmpty { + PlaybackTrackInfo( + trackSupport = it, + ) + } + } +} + +@Composable +fun TranscodeInfo( + info: TranscodingInfo, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + SimpleTable( + listOf( + "Reason:" to info.transcodeReasons.joinToString(", "), + "HW Accel:" to info.hardwareAccelerationType?.toString(), + "Container:" to info.container, + "Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, + ), + ) + SimpleTable( + listOf( + "Video:" to "${info.videoCodec}, ${info.width}x${info.height}", + "Video Direct:" to info.isVideoDirect.toString(), + "Audio:" to "${info.audioCodec}, ch=${info.audioChannels}", + "Audio Direct:" to info.isAudioDirect.toString(), + ), + ) + } +} + +@Composable +fun SimpleTable( + rows: List<Pair<String, String?>>, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier, + ) { + rows.forEach { + Text( + text = it.first, + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier, + ) { + rows.forEach { + Text( + text = it.second.toString(), + ) + } + } + } +} 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 3165246f..acce5f94 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 @@ -1,553 +1,174 @@ 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 -import androidx.compose.foundation.layout.padding -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 import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection -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.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.AppPreference -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent -import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -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 +import timber.log.Timber enum class PlaybackDialogType { - DEBUG, + MORE, CAPTIONS, SETTINGS, AUDIO, PLAYBACK_SPEED, VIDEO_SCALE, - SUBTITLE_DELAY, - BITRATE, } data class PlaybackSettings( val showDebugInfo: Boolean, val audioIndex: Int?, - val audioStreams: List<SimpleMediaStream>, + val audioStreams: List<AudioStream>, val subtitleIndex: Int?, - val subtitleStreams: List<SimpleMediaStream>, + val subtitleStreams: List<SubtitleStream>, val playbackSpeed: Float, val contentScale: ContentScale, - val subtitleDelay: Duration, - val hasSubtitleDownloadPermission: Boolean, - val playbackSpeedEnabled: Boolean, - val maxBitrate: Long, ) -/** - * Centralized UI component for displaying dialogs during playback - * - * Typically, the user will click something generating a [com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction] which translates into the - * [PlaybackDialogType] determining which dialog is shown by this component. - * - * @see com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction - */ @Composable fun PlaybackDialog( - enableSubtitleDelay: Boolean, - enableVideoScale: Boolean, type: PlaybackDialogType, settings: PlaybackSettings, onDismissRequest: () -> Unit, onControllerInteraction: () -> Unit, onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, onPlaybackActionClick: (PlaybackAction) -> Unit, - onChangeSubtitleDelay: (Duration) -> Unit, ) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - // TODO, shouldn't this work out of the box? - val leftGravity = remember(isLtr) { if (isLtr) Gravity.START else Gravity.END } - val rightGravity = remember(isLtr) { if (isLtr) Gravity.END else Gravity.START } when (type) { - PlaybackDialogType.DEBUG -> { - throw IllegalStateException("Should not open a dialog with " + PlaybackDialogType.DEBUG) + PlaybackDialogType.MORE -> { + val options = + buildList { + add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) + } + BottomDialog( + choices = options, + onDismissRequest = { + onDismissRequest.invoke() +// focusRequester.tryRequestFocus() + }, + onSelectChoice = { index, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ShowDebug) + }, + gravity = Gravity.START, + ) } PlaybackDialogType.CAPTIONS -> { - SubtitleChoiceBottomDialog( - choices = settings.subtitleStreams, - currentChoice = settings.subtitleIndex, - hasDownloadPermission = settings.hasSubtitleDownloadPermission, + val subtitleStreams = settings.subtitleStreams + val options = subtitleStreams.map { it.displayName } + Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") + val currentChoice = + subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size + BottomDialog( + choices = + options + + listOf( + stringResource(R.string.none), + stringResource(R.string.search_and_download), + ), + currentChoice = currentChoice, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() +// scope.launch { +// // TODO this is hacky, but playback changes force refocus and this is a workaround +// delay(250L) +// captionFocusRequester.tryRequestFocus() +// } }, - onSelectChoice = { subtitleIndex -> - onDismissRequest.invoke() - if (subtitleIndex >= 0) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) - } else if (subtitleIndex == TrackIndex.DISABLED) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) - } else if (subtitleIndex == TrackIndex.ONLY_FORCED) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED)) + onSelectChoice = { index, _ -> + if (index in subtitleStreams.indices) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) + } else { + val idx = index - subtitleStreams.size + if (idx == 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) + } else { + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) + } } }, - onSelectSearch = { - onDismissRequest.invoke() - onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) - }, - gravity = rightGravity, + gravity = Gravity.END, ) } PlaybackDialogType.SETTINGS -> { val options = - buildList { - add( - BottomDialogItem( - data = PlaybackDialogType.PLAYBACK_SPEED, - headline = stringResource(R.string.playback_speed), - supporting = settings.playbackSpeed.toString(), - enabled = settings.playbackSpeedEnabled, - ), - ) - if (enableVideoScale) { - add( - BottomDialogItem( - data = PlaybackDialogType.VIDEO_SCALE, - headline = stringResource(R.string.video_scale), - supporting = - playbackScaleOptions[settings.contentScale] - ?.let { stringResource(it) }, - ), - ) - } - if (enableSubtitleDelay) { - add( - BottomDialogItem( - data = PlaybackDialogType.SUBTITLE_DELAY, - headline = stringResource(R.string.subtitle_delay), - supporting = settings.subtitleDelay.toString(), - ), - ) - } - add( - BottomDialogItem( - data = PlaybackDialogType.BITRATE, - headline = stringResource(R.string.max_bitrate), - supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) }, - ), - ) - add( - BottomDialogItem( - data = PlaybackDialogType.DEBUG, - headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), - supporting = null, - ), - ) - } + listOf( + stringResource(R.string.audio), + stringResource(R.string.playback_speed), + stringResource(R.string.video_scale), + ) BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, - onSelectChoice = { _, choice -> - if (choice.data == PlaybackDialogType.DEBUG) { - onPlaybackActionClick.invoke(PlaybackAction.ShowDebug) - } else { - onClickPlaybackDialogType(choice.data) + onSelectChoice = { index, _ -> + when (index) { + 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) + 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) + 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) } }, - gravity = leftGravity, + gravity = Gravity.END, ) } - PlaybackDialogType.AUDIO -> { - StreamChoiceBottomDialog( - choices = settings.audioStreams, - currentChoice = settings.audioIndex, - onDismissRequest = { - onControllerInteraction.invoke() - onDismissRequest.invoke() - }, - onSelectChoice = { _, choice -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) - }, - gravity = rightGravity, - ) - } - - PlaybackDialogType.PLAYBACK_SPEED -> { - val choices = - playbackSpeedOptions.map { - BottomDialogItem( - data = it.toFloat(), - headline = it, - supporting = null, - ) - } + PlaybackDialogType.AUDIO -> BottomDialog( - choices = choices, - currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed }, + choices = settings.audioStreams.map { it.displayName }, + currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() +// scope.launch { +// delay(250L) +// settingsFocusRequester.tryRequestFocus() +// } + }, + onSelectChoice = { index, _ -> + onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) + }, + gravity = Gravity.END, + ) + + PlaybackDialogType.PLAYBACK_SPEED -> + BottomDialog( + choices = playbackSpeedOptions, + currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), + onDismissRequest = { + onControllerInteraction.invoke() + onDismissRequest.invoke() +// scope.launch { +// delay(250L) +// settingsFocusRequester.tryRequestFocus() +// } }, onSelectChoice = { _, value -> - onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) + onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) }, - gravity = leftGravity, + gravity = Gravity.END, ) - } - PlaybackDialogType.VIDEO_SCALE -> { - val choices = - playbackScaleOptions.map { (scale, name) -> - BottomDialogItem( - data = scale, - headline = stringResource(name), - supporting = null, - ) - } + PlaybackDialogType.VIDEO_SCALE -> BottomDialog( - choices = choices, - currentChoice = choices.firstOrNull { it.data == settings.contentScale }, + choices = playbackScaleOptions.values.toList(), + currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() +// scope.launch { +// delay(250L) +// settingsFocusRequester.tryRequestFocus() +// } }, - onSelectChoice = { _, choice -> - onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) + onSelectChoice = { index, _ -> + onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) }, - gravity = leftGravity, + gravity = Gravity.END, ) - } - - PlaybackDialogType.SUBTITLE_DELAY -> { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.setDimAmount(0f) - - Box( - modifier = - Modifier - .wrapContentSize() - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(16.dp), - ), - ) { - SubtitleDelay( - delay = settings.subtitleDelay, - onChangeDelay = onChangeSubtitleDelay, - modifier = Modifier.padding(8.dp), - ) - } - } - } - - PlaybackDialogType.BITRATE -> { - val choices = - AppPreference.bitrateValues.map { bitrate -> - BottomDialogItem( - data = bitrate, - headline = formatMaxBitrate(bitrate), - supporting = null, - ) - } - BottomDialog( - choices = choices, - currentChoice = choices.firstOrNull { it.data == settings.maxBitrate }, - onDismissRequest = { - onControllerInteraction.invoke() - onDismissRequest.invoke() - }, - onSelectChoice = { _, choice -> - onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data)) - }, - gravity = leftGravity, - ) - } - } -} - -@Composable -fun SubtitleChoiceBottomDialog( - choices: List<SimpleMediaStream>, - onDismissRequest: () -> Unit, - onSelectChoice: (Int) -> Unit, - onSelectSearch: () -> Unit, - gravity: Int, - 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, - properties = DialogProperties(usePlatformDefaultWidth = true), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre - window.setDimAmount(0f) // Remove dimmed background of ongoing playback - } - - Box( - modifier = - Modifier - .wrapContentSize() - .padding(8.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - shape = RoundedCornerShape(8.dp), - ), - ) { - LazyColumn( - state = listState, - modifier = - Modifier - .fillMaxWidth() -// .widthIn(max = 240.dp) - .wrapContentWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - ListItem( - selected = currentChoice == TrackIndex.DISABLED, - interactionSource = interactionSource, - onClick = { - onSelectChoice(TrackIndex.DISABLED) - }, - leadingContent = { - SelectedLeadingContent(currentChoice == TrackIndex.DISABLED) - }, - headlineContent = { - Text( - text = stringResource(R.string.none), - ) - }, - 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 { - ListItem( - selected = currentChoice == TrackIndex.ONLY_FORCED, - onClick = { - onSelectChoice(TrackIndex.ONLY_FORCED) - }, - leadingContent = { - SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED) - }, - headlineContent = { - Text( - text = stringResource(R.string.only_forced_subtitles), - ) - }, - supportingContent = {}, - ) - if (choices.isNotEmpty()) { - HorizontalDivider() - } - } - itemsIndexed(choices) { index, choice -> - val interactionSource = remember { MutableInteractionSource() } - ListItem( - selected = choice.index == currentChoice, - onClick = { - onSelectChoice(choice.index) - }, - leadingContent = { - SelectedLeadingContent(choice.index == currentChoice) - }, - headlineContent = { - Text( - text = choice.streamTitle ?: choice.displayTitle, - ) - }, - supportingContent = { - 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, - onClick = onSelectSearch, - leadingContent = {}, - headlineContent = { - Text( - text = stringResource(R.string.search_and_download), - ) - }, - 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 - }, - ) - } - } - } - } -} - -@Composable -fun StreamChoiceBottomDialog( - choices: List<SimpleMediaStream>, - onDismissRequest: () -> Unit, - onSelectChoice: (Int, SimpleMediaStream) -> Unit, - gravity: Int, - currentChoice: Int? = null, -) { - val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } - if (currentChoice != null) { - LaunchedEffect(Unit) { - choices.indexOfFirstOrNull { it.index == currentChoice }?.let { - focusRequesters.getOrNull(it)?.tryRequestFocus() - } - } - } - // TODO enforcing a width ends up ignore the gravity - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = true), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre - window.setDimAmount(0f) // Remove dimmed background of ongoing playback - } - - Box( - modifier = - Modifier - .wrapContentSize() - .padding(8.dp) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), - shape = RoundedCornerShape(8.dp), - ), - ) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() -// .widthIn(max = 240.dp) - .wrapContentWidth(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - itemsIndexed(choices) { index, choice -> - val interactionSource = remember { MutableInteractionSource() } - ListItem( - selected = choice.index == currentChoice, - onClick = { - onDismissRequest() - onSelectChoice(index, choice) - }, - leadingContent = { - SelectedLeadingContent(choice.index == currentChoice) - }, - headlineContent = { - Text( - text = choice.streamTitle ?: choice.displayTitle, - ) - }, - supportingContent = { - if (choice.streamTitle != null) Text(choice.displayTitle) - }, - interactionSource = interactionSource, - modifier = Modifier.focusRequester(focusRequesters[index]), - ) - } - } - } } } 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 1659d4a7..3715f458 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,7 +7,6 @@ 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 @@ -21,49 +20,35 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, - private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, - private val 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 val onSeekToNext: () -> Unit, - private val onPSeekToPrevious: () -> Unit, ) { - private var leftHandledByRepeat = false - private var rightHandledByRepeat = false - fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - if (!controlsEnabled) { - return false - } else if (handleHoldSkip(it)) { - return true - } else if (it.type != KeyEventType.KeyUp) { - return false - } - var result = true - if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + if (!controlsEnabled) { + result = false + } else if (it.type != KeyEventType.KeyUp) { + result = false + } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { - seekBy(-seekBack) + updateSkipIndicator(-seekBack.inWholeMilliseconds) + player.seekBack(seekBack) } else if (skipWithLeftRight && isSkipForward(it)) { - seekBy(seekForward) - } else if (isEnterKey(it) && isDpadSeekVisible()) { - // If d-pad seek bar is visible, hide it - clearSkipIndicator.invoke() + player.seekForward(seekForward) + updateSkipIndicator(seekForward.inWholeMilliseconds) } else if (oneClickPause && isEnterKey(it)) { - val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) - if (!wasPlaying) { + if (!player.isPlaying) { + controllerViewState.showControls() + } else { skipBackOnResume?.let { player.seekBack(it) } @@ -76,8 +61,27 @@ class PlaybackKeyHandler( } } else if (isMedia(it)) { when (it.key) { - Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> { - // no-op, MediaSession will handle + Key.MediaPlay -> { + Util.handlePlayButtonAction(player) + skipBackOnResume?.let { + player.seekBack(it) + } + } + + Key.MediaPause -> { + Util.handlePauseButtonAction(player) + controllerViewState.showControls() + } + + Key.MediaPlayPause -> { + Util.handlePlayPauseButtonAction(player) + if (!player.isPlaying) { + controllerViewState.showControls() + } else { + skipBackOnResume?.let { + player.seekBack(it) + } + } } Key.MediaFastForward, Key.MediaSkipForward -> { @@ -90,29 +94,14 @@ class PlaybackKeyHandler( updateSkipIndicator(-seekBack.inWholeMilliseconds) } - Key.MediaNext -> { - onSeekToNext.invoke() - } + Key.MediaNext -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext() + Key.MediaPrevious -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious() - Key.MediaPrevious -> { - onPSeekToPrevious.invoke() - } + Key.Captions -> onPlaybackDialogTypeClick.invoke(PlaybackDialogType.CAPTIONS) + Key.MediaAudioTrack -> onPlaybackDialogTypeClick.invoke(PlaybackDialogType.AUDIO) + Key.MediaStop -> onStop.invoke() - Key.Captions -> { - onPlaybackDialogTypeClick.invoke(PlaybackDialogType.CAPTIONS) - } - - Key.MediaAudioTrack -> { - onPlaybackDialogTypeClick.invoke(PlaybackDialogType.AUDIO) - } - - Key.MediaStop -> { - onStop.invoke() - } - - else -> { - result = false - } + else -> result = false } } else if (isEnterKey(it) && !controllerViewState.controlsVisible) { controllerViewState.showControls() @@ -125,96 +114,4 @@ class PlaybackKeyHandler( } return result } - - private fun handleHoldSkip(event: KeyEvent): Boolean { - if ( - controllerViewState.controlsVisible || - !skipWithLeftRight || - (!isSkipBack(event) && !isSkipForward(event)) - ) { - return false - } - - val isBack = isSkipBack(event) - return when (event.type) { - KeyEventType.KeyDown -> { - val repeatCount = event.nativeKeyEvent.repeatCount - if (repeatCount > 0) { - if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { - setHandledByRepeat(isBack = isBack, handled = false) - return true - } - val multiplier = - calculateSeekAccelerationMultiplier( - repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, - durationMs = normalizedDurationMs(), - ) - setHandledByRepeat(isBack = isBack, handled = true) - seekWithMultiplier(isBack = isBack, multiplier = multiplier) - } else { - setHandledByRepeat(isBack = isBack, handled = false) - } - true - } - - KeyEventType.KeyUp -> { - if (!handledByRepeat(isBack = isBack)) { - seekWithMultiplier(isBack = isBack, multiplier = 1) - } - setHandledByRepeat(isBack = isBack, handled = false) - true - } - - else -> { - false - } - } - } - - private fun seekWithMultiplier( - isBack: Boolean, - multiplier: Int, - ) { - if (isBack) { - val skipDuration = seekBack * multiplier - seekBy(-skipDuration) - } else { - val skipDuration = seekForward * multiplier - 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) - } - } - - private fun setHandledByRepeat( - isBack: Boolean, - handled: Boolean, - ) { - if (isBack) { - leftHandledByRepeat = handled - } else { - rightHandledByRepeat = handled - } - } - - private fun handledByRepeat(isBack: Boolean): Boolean = - if (isBack) { - leftHandledByRepeat - } else { - rightHandledByRepeat - } - - private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt new file mode 100644 index 00000000..eea34233 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -0,0 +1,557 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.Player +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.TimeFormatter +import com.github.damontecres.wholphin.ui.cards.ChapterCard +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.api.TrickplayInfo +import java.time.LocalTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val titleTextSize = 28.sp +private val subtitleTextSize = 18.sp + +/** + * The overlay during playback showing controls, seek preview image, debug info, etc + */ +@Composable +fun PlaybackOverlay( + item: BaseItem?, + chapters: List<Chapter>, + playerControls: Player, + controllerViewState: ControllerViewState, + showPlay: Boolean, + showClock: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + seekEnabled: Boolean, + seekBack: Duration, + skipBackOnResume: Duration?, + seekForward: Duration, + onPlaybackActionClick: (PlaybackAction) -> Unit, + onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + onSeekBarChange: (Long) -> Unit, + showDebugInfo: Boolean, + currentPlayback: CurrentPlayback?, + currentSegment: MediaSegmentDto?, + modifier: Modifier = Modifier, + trickplayInfo: TrickplayInfo? = null, + trickplayUrlFor: (Int) -> String? = { null }, + playlist: Playlist = Playlist(listOf(), 0), + onClickPlaylist: (BaseItem) -> Unit = {}, + seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() + // Will be used for preview/trick play images + var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) } + var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat() + + val chapterInteractionSources = + remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } + + val density = LocalDensity.current + + val titleHeight = + remember { + if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp + } + val subtitleHeight = + remember { + if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp + } + + // This will be calculated after composition + var controllerHeight by remember { mutableStateOf(0.dp) } + var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } + + Box( + modifier = modifier, + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility( + state == OverlayViewState.CONTROLLER, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + val nextState = + if (chapters.isNotEmpty()) { + OverlayViewState.CHAPTERS + } else if (playlist.hasNext()) { + OverlayViewState.QUEUE + } else { + null + } + Controller( + title = item?.title, + subtitle = item?.subtitleLong, + playerControls = playerControls, + controllerViewState = controllerViewState, + showPlay = showPlay, + showClock = showClock, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBack = seekBack, + skipBackOnResume = skipBackOnResume, + seekForward = seekForward, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = onClickPlaybackDialogType, + onSeekProgress = { + onSeekBarChange(it) + seekProgressMs = it + }, + seekBarInteractionSource = seekBarInteractionSource, + nextState = nextState, + onNextStateFocus = { + nextState?.let { state = it } + }, + currentSegment = currentSegment, + modifier = + Modifier + // Don't use key events because this control has vertical items so up/down is tough to manage + .onGloballyPositioned { + controllerHeight = with(density) { it.size.height.toDp() } + }, + ) + } + AnimatedVisibility( + state == OverlayViewState.CHAPTERS, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ) { + if (chapters.isNotEmpty()) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp && isUp(e)) { + state = OverlayViewState.CONTROLLER + true + } else { + false + } + }, + ) { + Text( + text = stringResource(R.string.chapters), + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }, + ) { + itemsIndexed(chapters) { index, chapter -> + val interactionSource = chapterInteractionSources[index] + val isFocused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + ChapterCard( + name = chapter.name, + position = chapter.position, + imageUrl = chapter.imageUrl, + aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE, + onClick = { + playerControls.seekTo(chapter.position.inWholeMilliseconds) + controllerViewState.hideControls() + }, + interactionSource = interactionSource, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + if (playlist.hasNext()) { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) state = OverlayViewState.QUEUE + }.focusable(), + ) + } + } + } + } + AnimatedVisibility( + state == OverlayViewState.QUEUE, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ) { + if (playlist.hasNext()) { + val items = remember { playlist.upcomingItems() } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp && isUp(e)) { + if (chapters.isNotEmpty()) { + state = OverlayViewState.CHAPTERS + } else { + state = OverlayViewState.CONTROLLER + } + true + } else if (isDown(e)) { + true + } else { + false + } + }, + ) { + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }, + ) { + itemsIndexed(items) { index, item -> + val interactionSource = remember { MutableInteractionSource() } + val isFocused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + SeasonCard( + item = item, + onClick = { + onClickPlaylist.invoke(item) + controllerViewState.hideControls() + }, + onLongClick = {}, + imageHeight = 140.dp, + interactionSource = interactionSource, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } + } + } + + if (seekBarInteractionSource.collectIsFocusedAsState().value) { + LaunchedEffect(Unit) { + seekProgressPercent = + (playerControls.currentPosition.toFloat() / playerControls.duration) + } + } + + AnimatedVisibility( + seekProgressPercent >= 0 && seekBarFocused, + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .fillMaxWidth(.95f), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent( + xPercentage = seekProgressPercent.coerceIn(0f, 1f), + ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), + ) { + if (trickplayInfo != null) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val index = + (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage + val imageUrl = trickplayUrlFor(index) + + if (imageUrl != null) { + SeekPreviewImage( + modifier = + Modifier, + previewImageUrl = imageUrl, + duration = playerControls.duration, + seekProgressMs = seekProgressMs, + videoWidth = trickplayInfo.width, + videoHeight = trickplayInfo.height, + trickPlayInfo = trickplayInfo, + ) + } + } + Text( + text = (seekProgressMs / 1000L).seconds.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + AnimatedVisibility( + !showDebugInfo && item?.logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + modifier = + Modifier + .align(Alignment.TopStart), + ) { + AsyncImage( + model = item?.logoImageUrl, + contentDescription = "Logo", + modifier = + Modifier + .sizeIn(maxWidth = 180.dp, maxHeight = 100.dp) + .padding(16.dp), + ) + } + AnimatedVisibility( + showDebugInfo && controllerViewState.controlsVisible, + modifier = + Modifier + .align(Alignment.TopStart), + ) { + PlaybackDebugOverlay( + currentPlayback = currentPlayback, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .background(AppColors.TransparentBlack50), + ) + } + } +} + +/** + * The view state of the overlay + */ +enum class OverlayViewState { + CONTROLLER, + CHAPTERS, + QUEUE, +} + +/** + * A wrapper for the playback controls to show title and other information, plus the actual controls + */ +@Composable +fun Controller( + title: String?, + playerControls: Player, + controllerViewState: ControllerViewState, + showClock: Boolean, + showPlay: Boolean, + previousEnabled: Boolean, + nextEnabled: Boolean, + seekEnabled: Boolean, + seekBack: Duration, + skipBackOnResume: Duration?, + seekForward: Duration, + onPlaybackActionClick: (PlaybackAction) -> Unit, + onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, + onSeekProgress: (Long) -> Unit, + nextState: OverlayViewState?, + currentSegment: MediaSegmentDto?, + modifier: Modifier = Modifier, + subtitle: String? = null, + seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onNextStateFocus: () -> Unit = {}, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 16.dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontSize = titleTextSize, + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.End), + ) { + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontSize = subtitleTextSize, + ) + } + if (showClock) { + var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) } + LaunchedEffect(playerControls) { + while (isActive) { + remaining = + (playerControls.duration - playerControls.currentPosition).milliseconds + delay(1.seconds) + } + } + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + val endTimeStr = TimeFormatter.format(endTime) + Text( + text = "Ends $endTimeStr", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) + } + } + } + // TODO need to move these up a level? + val moreFocusRequester = remember { FocusRequester() } + val captionFocusRequester = remember { FocusRequester() } + val settingsFocusRequester = remember { FocusRequester() } + PlaybackControls( + modifier = Modifier.fillMaxWidth(), + playerControls = playerControls, + onPlaybackActionClick = onPlaybackActionClick, + controllerViewState = controllerViewState, + onSeekProgress = { + onSeekProgress(it) + }, + showPlay = showPlay, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBarInteractionSource = seekBarInteractionSource, + seekBarIntervals = 16, + seekBack = seekBack, + seekForward = seekForward, + skipBackOnResume = skipBackOnResume, + currentSegment = currentSegment, + onClickPlaybackDialogType = onClickPlaybackDialogType, + moreFocusRequester = moreFocusRequester, + captionFocusRequester = captionFocusRequester, + settingsFocusRequester = settingsFocusRequester, + ) + when (nextState) { + OverlayViewState.CHAPTERS -> + Text( + text = stringResource(R.string.chapters), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + OverlayViewState.QUEUE -> + Text( + text = stringResource(R.string.queue), + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + else -> Spacer(Modifier.height(32.dp)) + } + } +} 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 86d64651..1600cc9f 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 @@ -1,16 +1,10 @@ package com.github.damontecres.wholphin.ui.playback -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 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 @@ -28,12 +22,11 @@ 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 +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -46,18 +39,14 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.intl.Locale 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 import androidx.lifecycle.compose.LifecycleStartEffect import androidx.media3.common.util.UnstableApi @@ -67,41 +56,33 @@ import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState +import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text 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.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.Playlist 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.OneTimeLaunchedEffect 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.applyToMpv +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.skipStringRes 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 io.github.peerless2012.ass.media.widget.AssSubtitleView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.extensions.ticks -import timber.log.Timber -import kotlin.time.Duration +import java.util.UUID import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -112,333 +93,214 @@ import kotlin.time.Duration.Companion.seconds @Composable fun PlaybackPage( preferences: UserPreferences, + deviceProfile: DeviceProfile, destination: Destination, modifier: Modifier = Modifier, - viewModel: PlaybackViewModel = - hiltViewModel<PlaybackViewModel, PlaybackViewModel.Factory>( - creationCallback = { it.create(destination) }, - ), + viewModel: PlaybackViewModel = hiltViewModel(), ) { LifecycleStartEffect(destination) { onStopOrDispose { viewModel.release() } } - val state by viewModel.state.collectAsState() - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + LaunchedEffect(destination) { + viewModel.init(destination, deviceProfile, preferences) + } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) LoadingState.Pending, LoadingState.Loading, - -> { - LoadingPage(modifier.background(Color.Black)) - } + -> LoadingPage(modifier.background(Color.Black)) LoadingState.Success -> { - val playerState by viewModel.currentPlayer.collectAsState() - PlaybackPageContent( - playerInstance = playerState!!, - preferences = preferences, - destination = destination, - viewModel = viewModel, - modifier = modifier, + val prefs = preferences.appPreferences.playbackPreferences + val scope = rememberCoroutineScope() + val density = LocalDensity.current + + val player = viewModel.player + val mediaInfo by viewModel.currentMediaInfo.observeAsState() + + val currentPlayback by viewModel.currentPlayback.collectAsState() + val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( + ItemPlayback( + userId = -1, + itemId = UUID.randomUUID(), + ), ) - } - } -} + val currentSegment by viewModel.currentSegment.observeAsState(null) + var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } -@OptIn(UnstableApi::class) -@Composable -fun PlaybackPageContent( - playerInstance: PlayerInstance, - preferences: UserPreferences, - destination: Destination, - modifier: Modifier = Modifier, - viewModel: PlaybackViewModel, -) { - val state by viewModel.state.collectAsState() - val subtitleSearchState by viewModel.subtitleSearchState.collectAsState() - val player = playerInstance.player - val playerBackend = playerInstance.backend + val cues by viewModel.subtitleCues.observeAsState(listOf()) + var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - val prefs = preferences.appPreferences.playbackPreferences - val scope = rememberCoroutineScope() - val density = LocalDensity.current - LaunchedEffect(density) { viewModel.updateDensity(density) } - val userDto by viewModel.currentUserDto.collectAsState() + val nextUp by viewModel.nextUp.observeAsState(null) + val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } + val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) + val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) - var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } + var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) } - var contentScale by remember(playerBackend) { - mutableStateOf( - if (playerBackend == PlayerBackend.MPV) { - ContentScale.FillBounds - } else { - prefs.globalContentScale.scale - }, - ) - } - var playbackSpeed by remember { mutableFloatStateOf(1.0f) } - LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - - LaunchedEffect(state.currentPlayback?.subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = - state.currentPlayback?.subtitleDelay ?: Duration.ZERO - } - - val presentationState = rememberPresentationState(player, false) - val playbackState by rememberPlayerState(player) - var showBuffering by remember { mutableStateOf(false) } - LaunchedEffect(playbackState) { - if (playbackState == PlayerState.BUFFERING) { - // Delay before showing the loading indicator - // So if buffering is quick, the UI won't flash - delay(250) - showBuffering = true - } else { - showBuffering = false - } - } - val scaledModifier = - Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) - val focusRequester = remember { FocusRequester() } - val playPauseState = rememberPlayPauseButtonState(player) - val seekBarState = rememberSeekBarState(player, scope) - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - val controllerViewState = remember { viewModel.controllerViewState } - - var skipIndicatorDuration by remember { mutableLongStateOf(0L) } - LaunchedEffect(controllerViewState.controlsVisible) { - // If controller shows/hides, immediately cancel the skip indicator - skipIndicatorDuration = 0L - } - var skipPosition by remember { mutableLongStateOf(0L) } - val updateSkipIndicator = { delta: Long -> - if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) { - skipIndicatorDuration = 0 - } - skipIndicatorDuration += delta - skipPosition = player.currentPosition - } - 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(preferences) { - PlaybackKeyHandler( - player = player, - controlsEnabled = state.nextUp == null, - skipWithLeftRight = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - 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, - onStop = { - player.stop() - viewModel.navigationManager.goBack() - }, - onPlaybackDialogTypeClick = { playbackDialog = it }, - isDpadSeekVisible = { - prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_TRICKPLAY && skipIndicatorDuration != 0L - }, - onDpadSeek = onDpadSeek, - dpadSeekMode = prefs.dpadSeekMode, - onSeekToNext = viewModel::playNextUp, - onPSeekToPrevious = viewModel::playPrevious, - ) - } - - val onPlaybackActionClick: (PlaybackAction) -> Unit = { - when (it) { - is PlaybackAction.PlaybackSpeed -> { - playbackSpeed = it.value + OneTimeLaunchedEffect { + if (prefs.playerBackend == PlayerBackend.MPV) { + scope.launch(Dispatchers.Main + ExceptionHandler()) { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + density, + ) + } + } } + AmbientPlayerListener(player) + var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } + LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - is PlaybackAction.Scale -> { - contentScale = it.scale + val presentationState = rememberPresentationState(player, false) + val scaledModifier = + Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) + val focusRequester = remember { FocusRequester() } + val playPauseState = rememberPlayPauseButtonState(player) + val seekBarState = rememberSeekBarState(player, scope) + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() } + val controllerViewState = remember { viewModel.controllerViewState } - PlaybackAction.ShowDebug -> { - showDebugInfo = !showDebugInfo + var skipIndicatorDuration by remember { mutableLongStateOf(0L) } + LaunchedEffect(controllerViewState.controlsVisible) { + // If controller shows/hides, immediately cancel the skip indicator + skipIndicatorDuration = 0L } - - PlaybackAction.ShowPlaylist -> { - TODO() + var skipPosition by remember { mutableLongStateOf(0L) } + val updateSkipIndicator = { delta: Long -> + if (skipIndicatorDuration > 0 && delta < 0 || skipIndicatorDuration < 0 && delta > 0) { + skipIndicatorDuration = 0 + } + skipIndicatorDuration += delta + skipPosition = player.currentPosition } + val keyHandler = + PlaybackKeyHandler( + player = player, + controlsEnabled = nextUp == null, + skipWithLeftRight = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, + onStop = { + player.stop() + viewModel.navigationManager.goBack() + }, + onPlaybackDialogTypeClick = { playbackDialog = it }, + ) - PlaybackAction.ShowVideoFilterDialog -> { - TODO() - } + val onPlaybackActionClick: (PlaybackAction) -> Unit = { + when (it) { + is PlaybackAction.PlaybackSpeed -> { + playbackSpeed = it.value + } - is PlaybackAction.ToggleAudio -> { - viewModel.changeAudioStream(it.index) - } + is PlaybackAction.Scale -> { + contentScale = it.scale + } - is PlaybackAction.ToggleCaptions -> { - viewModel.changeSubtitleStream(it.index) - } + PlaybackAction.ShowDebug -> { + showDebugInfo = !showDebugInfo + } - PlaybackAction.SearchCaptions -> { - controllerViewState.hideControls() - viewModel.searchForSubtitles() - } + PlaybackAction.ShowPlaylist -> TODO() + PlaybackAction.ShowVideoFilterDialog -> TODO() + is PlaybackAction.ToggleAudio -> { + viewModel.changeAudioStream(it.index) + } - PlaybackAction.Next -> { - // TODO focus is lost - viewModel.playNextUp() - } + is PlaybackAction.ToggleCaptions -> { + viewModel.changeSubtitleStream(it.index) + } - PlaybackAction.Previous -> { - val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && state.hasPrevious) { - viewModel.playPrevious() - } else { - player.seekToPrevious() + PlaybackAction.SearchCaptions -> { + controllerViewState.hideControls() + viewModel.searchForSubtitles() + } + + PlaybackAction.Next -> { + // TODO focus is lost + viewModel.playNextUp() + } + + PlaybackAction.Previous -> { + val pos = player.currentPosition + if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + viewModel.playPrevious() + } else { + player.seekToPrevious() + } + } } } - is PlaybackAction.ChangeBitrate -> { - viewModel.changeMaxBitrate(it.maxBitrate) + val showSegment = + !segmentCancelled && currentSegment != null && + nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + BackHandler(showSegment) { + segmentCancelled = true } - } - } - val showSegment = - state.currentSegment?.interacted == false && - state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L - BackHandler(showSegment) { - viewModel.updateSegment(state.currentSegment?.segment?.id, true) - } - - Box( - modifier - .background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background), - ) { - val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f) - Box( - modifier = - Modifier - .fillMaxSize(playerSize) - .align(Alignment.TopCenter) - .onKeyEvent(keyHandler::onKeyEvent) - .focusRequester(focusRequester) - .focusable(), - ) { - var playerSurfaceSize by remember { mutableStateOf(IntSize.Zero) } - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier.onSizeChanged { - playerSurfaceSize = it - }, - ) - if (presentationState.coverSurface) { + Box( + modifier + .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), + ) { + val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) { - LoadingPage(focusEnabled = false) - } - } else { - AnimatedVisibility( - visible = showBuffering, - enter = fadeIn(tween(easing = LinearEasing)), - exit = fadeOut(tween(easing = LinearEasing)), - modifier = Modifier.matchParentSize(), - ) { - LoadingPage( - focusEnabled = false, - modifier = - Modifier - .fillMaxSize() - .background(AppColors.TransparentBlack25), - ) - } - } - - 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 && - prefs.dpadSeekMode != DpadSeekMode.SEEKBAR_TRICKPLAY - ) { - // Skip time mode: show seek distance indicator - SkipIndicator( - durationMs = skipIndicatorDuration, - onFinish = { - skipIndicatorDuration = 0L - }, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 70.dp), - ) - // Show a small progress bar along the bottom of the screen - if (prefs.dpadSeekMode == DpadSeekMode.SEEKBAR_MINIMAL) { - val percent = skipPosition.toFloat() / player.duration.toFloat() - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + .fillMaxSize(playerSize) + .align(Alignment.TopCenter) + .onKeyEvent(keyHandler::onKeyEvent) + .focusRequester(focusRequester) + .focusable(), + ) { + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = scaledModifier, + ) + if (presentationState.coverSurface) { + val isLoading by rememberPlayerLoadingState(player) Box( Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), + .matchParentSize() + .background(Color.Black), ) { + if (isLoading) { + LoadingPage(focusEnabled = false) + } + } + } + + // If D-pad skipping, show the amount skipped in an animation + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + SkipIndicator( + durationMs = skipIndicatorDuration, + onFinish = { + skipIndicatorDuration = 0L + }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 70.dp), + ) + // Show a small progress bar along the bottom of the screen + val showSkipProgress = true // TODO get from preferences + if (showSkipProgress) { + val percent = skipPosition.toFloat() / player.duration.toFloat() Box( modifier = Modifier @@ -450,342 +312,239 @@ fun PlaybackPageContent( ) } } - } - } - val controlsVisible = - remember(controllerViewState.controlsVisible, playbackDialog, subtitleSearchState) { - controllerViewState.controlsVisible || - playbackDialog != null || - subtitleSearchState.status != SubtitleSearchStatus.Inactive + // Subtitles + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + AndroidView( + factory = { context -> + SubtitleView(context).apply { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + setStyle(it.toSubtitleStyle()) + setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) + } + } + }, + update = { + it.setCues(cues) + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(maxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent), + ) + } + + // The playback controls + AnimatedVisibility( + controllerViewState.controlsVisible, + Modifier, + slideInVertically { it }, + slideOutVertically { it }, + ) { + PlaybackOverlay( + modifier = + Modifier + .padding(WindowInsets.systemBars.asPaddingValues()) + .fillMaxSize() + .background(Color.Transparent), + item = currentPlayback?.item, + playerControls = player, + controllerViewState = controllerViewState, + showPlay = playPauseState.showPlay, + previousEnabled = true, + nextEnabled = playlist.hasNext(), + seekEnabled = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = { playbackDialog = it }, + onSeekBarChange = seekBarState::onValueChange, + showDebugInfo = showDebugInfo, + currentPlayback = currentPlayback, + chapters = mediaInfo?.chapters ?: listOf(), + trickplayInfo = mediaInfo?.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, + currentSegment = currentSegment, + showClock = preferences.appPreferences.interfacePreferences.showClock, + ) + } } - if (!controlsVisible && skipIndicatorDuration == 0L) { - PauseIndicator( - player = player, + + // Ask to skip intros, etc button + AnimatedVisibility( + showSegment, modifier = Modifier - .align(Alignment.Center), - ) - } + .padding(40.dp) + .align(Alignment.BottomEnd), + ) { + currentSegment?.let { segment -> + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + delay(10.seconds) + segmentCancelled = true + } + Button( + onClick = { + segmentCancelled = true + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + }, + modifier = Modifier.focusRequester(focusRequester), + ) { + Text( + text = stringResource(segment.type.skipStringRes), + ) + } + } + } - // The playback controls - - PlaybackOverlay( - modifier = - Modifier - .padding(WindowInsets.systemBars.asPaddingValues()) - .fillMaxSize() - .background(Color.Transparent), - item = state.currentPlayback?.item, - player = player, - controllerViewState = controllerViewState, - showPlay = playPauseState.showPlay, - previousEnabled = true, - nextEnabled = state.hasNext, - seekEnabled = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = { playbackDialog = it }, - onSeekBarChange = seekBarState::onValueChange, - showDebugInfo = showDebugInfo, - currentPlayback = state.currentPlayback, - chapters = state.currentMediaInfo.chapters, - trickplayInfo = state.currentMediaInfo.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - 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}") - val useSeparate = - preferences.appPreferences.interfacePreferences.subtitlesPreferences.useSeparateHdr - if (useSeparate && state.currentMediaInfo.videoStream?.hdr == true) { - preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences + // Next up episode + BackHandler(nextUp != null) { + if (player.isPlaying) { + scope.launch(ExceptionHandler()) { + viewModel.cancelUpNextEpisode() + } } else { - preferences.appPreferences.interfacePreferences.subtitlesPreferences + viewModel.navigationManager.goBack() } } - val subtitleImageOpacity = - remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } - - // Subtitles - val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - val isImageSubtitles = - remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } - - // Reset cueCount when density changes to force re-applying subtitle style - var cueCount by remember(density) { mutableIntStateOf(0) } - - val subtitleVisible = - skipIndicatorDuration == 0L && - state.currentPlayback?.subtitleIndexEnabled == true && - !presentationState.coverSurface - - AndroidView( - factory = { context -> - SubtitleView(context).apply { - subtitleSettings.applyTo(this) - playerInstance.assHandler?.let { assHandler -> - if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { - Timber.v("Adding AssSubtitleView") - addView( - AssSubtitleView(context, assHandler).apply { - layoutParams = - FrameLayout - .LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ).apply { gravity = Gravity.CENTER } - }, - ) - } + AnimatedVisibility( + nextUp != null, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + nextUp?.let { + var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } + var timeLeft by remember { + mutableLongStateOf( + preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, + ) } - } - }, - 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) - cueCount = state.subtitleCues.size - } - subtitleView.children.firstOrNull { it is AssSubtitleView }?.let { - (it as? AssSubtitleView)?.apply { - val resized = - layoutParams.let { it.width != playerSurfaceSize.width || it.height != playerSurfaceSize.height } - - if (resized && playerSurfaceSize.width > 0 && playerSurfaceSize.height > 0) { - Timber.v("Resizing AssSubtitleView: %s", playerSurfaceSize) - layoutParams = - FrameLayout - .LayoutParams( - playerSurfaceSize.width, - playerSurfaceSize.height, - ).apply { gravity = Gravity.CENTER } - } + BackHandler(timeLeft > 0 && autoPlayEnabled) { + timeLeft = -1 + autoPlayEnabled = false } - } - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(subtitleMaxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent) - .graphicsLayer { - alpha = - if (!subtitleVisible) { - 0f - } else if (isImageSubtitles) { - subtitleImageOpacity + if (autoPlayEnabled) { + LaunchedEffect(Unit) { + if (timeLeft == 0L) { + viewModel.playNextUp() } else { - 1f + while (timeLeft > 0) { + delay(1.seconds) + timeLeft-- + } + if (timeLeft == 0L && autoPlayEnabled) { + viewModel.playNextUp() + } } - }, - ) - } + } + } + NextUpEpisode( + title = + listOfNotNull( + it.data.seasonEpisode, + it.name, + ).joinToString(" - "), + description = it.data.overview, + imageUrl = it.imageUrl, + aspectRatio = + it.data.primaryImageAspectRatio?.toFloat() + ?: AspectRatios.WIDE, + onClick = { + viewModel.reportInteraction() + viewModel.playNextUp() + }, + timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + modifier = + Modifier + .padding(8.dp) +// .height(128.dp) + .fillMaxHeight(1 - playerSize) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) + } + } + } - // Ask to skip intros, etc button - AnimatedVisibility( - showSegment, - modifier = - Modifier - .padding(40.dp) - .align(Alignment.BottomEnd), - ) { - state.currentSegment?.let { segment -> - val focusRequester = remember { FocusRequester() } + subtitleSearch?.let { state -> + val wasPlaying = remember { player.isPlaying } LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - delay(10.seconds) - viewModel.updateSegment(segment.segment.id, true) + player.pause() } - SkipSegmentButton( - type = segment.segment.type, - onClick = { - viewModel.updateSegment(segment.segment.id, false) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - - // Next up episode - BackHandler(state.nextUp != null) { - if (player.isPlaying) { - scope.launch(ExceptionHandler()) { - viewModel.cancelUpNextEpisode() + val onDismissRequest = { + if (wasPlaying) { + player.play() + } + viewModel.cancelSubtitleSearch() } - } else { - viewModel.navigationManager.goBack() - } - } - AnimatedVisibility( - state.nextUp != null, - modifier = - Modifier - .align(Alignment.BottomCenter), - ) { - state.nextUp?.let { - var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } - var timeLeft by remember { - mutableLongStateOf( - preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + DownloadSubtitlesContent( + state = state, + language = subtitleSearchLanguage, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + onDismissRequest = onDismissRequest, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), ) } - BackHandler(timeLeft > 0 && autoPlayEnabled) { - timeLeft = -1 - autoPlayEnabled = false - } - if (autoPlayEnabled) { - LaunchedEffect(Unit) { - if (timeLeft == 0L) { - viewModel.playNextUp() - } else { - while (timeLeft > 0) { - delay(1.seconds) - timeLeft-- - } - if (timeLeft == 0L && autoPlayEnabled) { - viewModel.playNextUp() - } + } + + playbackDialog?.let { type -> + PlaybackDialog( + type = type, + settings = + PlaybackSettings( + showDebugInfo = showDebugInfo, + audioIndex = currentItemPlayback?.audioIndex, + audioStreams = mediaInfo?.audioStreams.orEmpty(), + subtitleIndex = currentItemPlayback?.subtitleIndex, + subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), + playbackSpeed = playbackSpeed, + contentScale = contentScale, + ), + onDismissRequest = { + playbackDialog = null + if (controllerViewState.controlsVisible) { + controllerViewState.pulseControls() } - } - } - NextUpEpisode( - title = - listOfNotNull( - it.data.seasonEpisode, - it.name, - ).joinToString(" - "), - description = it.data.overview, - imageUrl = LocalImageUrlService.current.rememberImageUrl(it), - aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, - onClick = { - viewModel.reportInteraction() - controllerViewState.hideControls() - viewModel.playNextUp() }, - timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, - runtime = it.data.runTimeTicks?.ticks, - modifier = - Modifier - .padding(8.dp) -// .height(128.dp) - .fillMaxHeight(1 - playerSize) - .fillMaxWidth(.66f) - .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), + onControllerInteraction = { + controllerViewState.pulseControls(Long.MAX_VALUE) + }, + onClickPlaybackDialogType = { playbackDialog = it }, + onPlaybackActionClick = onPlaybackActionClick, ) } } } - - if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) { - val wasPlaying = remember { player.isPlaying } - LaunchedEffect(Unit) { - player.pause() - } - val onDismissRequest = { - if (wasPlaying) { - player.play() - } - viewModel.cancelSubtitleSearch() - } - BasicDialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - 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), - ) - } - } - } - - playbackDialog?.let { type -> - PlaybackDialog( - type = type, - settings = - PlaybackSettings( - showDebugInfo = showDebugInfo, - audioIndex = state.currentPlayback?.audioIndex, - audioStreams = state.currentMediaInfo.audioStreams, - subtitleIndex = state.currentPlayback?.subtitleIndex, - subtitleStreams = state.currentMediaInfo.subtitleStreams, - playbackSpeed = playbackSpeed, - contentScale = contentScale, - subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO, - hasSubtitleDownloadPermission = - remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, - // TODO Passing through audio prevents changing playback speed - // See https://github.com/damontecres/Wholphin/issues/164 - playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, - maxBitrate = state.maxBitrate, - ), - onDismissRequest = { - playbackDialog = - when (type) { - // Go back to settings dialog - PlaybackDialogType.PLAYBACK_SPEED, - PlaybackDialogType.VIDEO_SCALE, - -> PlaybackDialogType.SETTINGS - - else -> null - } - if (controllerViewState.controlsVisible) { - controllerViewState.pulseControls() - } - }, - onControllerInteraction = { - controllerViewState.pulseControls(Long.MAX_VALUE) - }, - onClickPlaybackDialogType = { - if (it == PlaybackDialogType.SUBTITLE_DELAY) { - // Hide controls so subtitles are fully visible - controllerViewState.hideControls() - } - playbackDialog = it - }, - onPlaybackActionClick = onPlaybackActionClick, - onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = player is MpvPlayer, - enableVideoScale = player !is MpvPlayer, - ) - } } 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 deleted file mode 100644 index cca3a0d0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.ui.text.intl.Locale -import androidx.media3.common.Player -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 -import io.github.peerless2012.ass.media.AssHandler -import org.jellyfin.sdk.model.api.MediaSegmentDto - -data class PlaybackState( - val loading: LoadingState = LoadingState.Loading, - val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY, - val currentPlayback: CurrentPlayback? = null, - val currentItemPlayback: ItemPlayback? = null, - val currentSegment: MediaSegmentState? = null, - val analyticsState: AnalyticsState = AnalyticsState(), - val subtitleCues: List<Cue> = emptyList(), - val nextUp: BaseItem? = null, - val playlistIndex: Int = 0, - val playlist: Playlist = Playlist(emptyList()), - val maxBitrate: Long = -1L, -) { - 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, - val backend: PlayerBackend, - val assHandler: AssHandler?, -) - -data class MediaSegmentState( - val segment: MediaSegmentDto, - val interacted: Boolean, -) - -data class AnalyticsState( - val bitrate: String = formatBitrate(0), - val bitrateEstimate: String = formatBitrate(0), - val droppedFrames: Int = 0, -) - -data class SubtitleSearchState( - val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive, - val language: String = Locale.current.language, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt new file mode 100644 index 00000000..4bd59347 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackTrackInfo.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.util.TrackSupport + +/** + * Debug info about the current playback tracks + */ +@Composable +fun PlaybackTrackInfo( + trackSupport: List<TrackSupport>, + modifier: Modifier = Modifier, +) { + val selectedWeight = .5f + val weights = listOf(.25f, .4f, .5f, 1f, 1f) + val textStyle = + MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface) + + LazyColumn( + modifier = modifier, + ) { + item { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier, + ) { + val texts = + listOf( + "ID", + "Type", + "Codec", + "Supported", + "Labels", + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(selectedWeight), + ) { + Text( + text = "Selected", + style = textStyle, + ) + } + texts.forEachIndexed { index, text -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(weights[index]), + ) { + Text( + text = text, + style = textStyle, + ) + } + } + } + } + items(trackSupport) { track -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier.ifElse( + track.selected, + Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)), + ), + ) { + val texts = + listOf( + track.id ?: "", + track.type.name, + track.codecs ?: "", + track.supported.name, + track.labels.joinToString(", "), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(selectedWeight), + ) { + if (track.selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.size(12.dp), + ) + } else { + Text( + text = "-", + style = textStyle, + ) + } + } + texts.forEachIndexed { index, text -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(weights[index]), + ) { + Text( + text = text, + style = textStyle, + ) + } + } + } + } + } +} 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 24bd8ee9..aca91ee1 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 @@ -1,97 +1,72 @@ package com.github.damontecres.wholphin.ui.playback import android.content.Context -import android.media.MediaCodecList -import android.os.Build import android.widget.Toast import androidx.annotation.OptIn -import androidx.annotation.VisibleForTesting -import androidx.compose.ui.unit.Density +import androidx.compose.ui.text.intl.Locale import androidx.core.net.toUri +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MediaItem -import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Tracks +import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener -import androidx.media3.session.MediaSession -import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.size.Size 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.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.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.data.model.chooseSource +import com.github.damontecres.wholphin.data.model.chooseStream 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 -import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator -import com.github.damontecres.wholphin.services.RefreshRateService -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.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 import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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.mpvDeviceProfile -import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.supportItemKinds -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.coroutineScope 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 import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -109,72 +84,49 @@ import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage -import org.jellyfin.sdk.model.api.PlaystateRequest -import org.jellyfin.sdk.model.api.TrickplayInfo -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.Date import java.util.UUID -import kotlin.math.roundToInt -import kotlin.time.Duration +import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** * This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes) */ -@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class) +@HiltViewModel @OptIn(markerClass = [UnstableApi::class]) class PlaybackViewModel - @AssistedInject + @Inject constructor( @param:ApplicationContext internal val context: Context, internal val api: ApiClient, val navigationManager: NavigationManager, private val playlistCreator: PlaylistCreator, private val itemPlaybackDao: ItemPlaybackDao, - internal val serverRepository: ServerRepository, - internal val itemPlaybackRepository: ItemPlaybackRepository, + private val serverRepository: ServerRepository, + private val itemPlaybackRepository: ItemPlaybackRepository, private val playerFactory: PlayerFactory, private val datePlayedService: DatePlayedService, private val deviceInfo: DeviceInfo, - private val deviceProfileService: DeviceProfileService, - private val refreshRateService: RefreshRateService, - val streamChoiceService: StreamChoiceService, - private val userPreferencesService: UserPreferencesService, - private val imageUrlService: ImageUrlService, - private val screensaverService: ScreensaverService, - private val musicService: MusicService, - @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, AnalyticsListener { - @AssistedFactory - interface Factory { - fun create(destination: Destination): PlaybackViewModel + val player by lazy { + playerFactory.createVideoPlayer() } - - val currentPlayer = MutableStateFlow<PlayerInstance?>(null) - - internal lateinit var player: Player - - private var mediaSession: MediaSession? = null - - // Mutex & Job for changing playlist index - private val playlistMutex = Mutex() - private var playlistJob: Job? = null + internal val mutex = Mutex() val controllerViewState = ControllerViewState( @@ -182,162 +134,101 @@ class PlaybackViewModel true, ) - private val _state = MutableStateFlow(PlaybackState()) - val state: StateFlow<PlaybackState> = _state + val loading = MutableLiveData<LoadingState>(LoadingState.Loading) - internal lateinit var preferences: UserPreferences + val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY) + val currentPlayback = MutableStateFlow<CurrentPlayback?>(null) + val currentItemPlayback = MutableLiveData<ItemPlayback>() + val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null) + private val autoSkippedSegments = mutableSetOf<UUID>() + + val subtitleCues = MutableLiveData<List<Cue>>(listOf()) + + private lateinit var preferences: UserPreferences + private lateinit var deviceProfile: DeviceProfile internal lateinit var itemId: UUID - internal lateinit var currentItem: PlaylistItem + internal lateinit var item: BaseItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null - private var trackChangeListener: TracksChangedListener? = null - private val jobs = mutableListOf<Job>() - private var subscribeJob: Job? = null - private val isPlaylist = destination is Destination.PlaybackList + val nextUp = MutableLiveData<BaseItem?>() + private var isPlaylist = false - val subtitleSearchState = MutableStateFlow(SubtitleSearchState()) - - val currentUserDto = serverRepository.currentUserDtoFlow - - @VisibleForTesting - val initJob: Job + val playlist = MutableLiveData<Playlist>(Playlist(listOf())) + val subtitleSearch = MutableLiveData<SubtitleSearch?>(null) + val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language) init { - initJob = - viewModelScope.launchIO { - addCloseable { - screensaverService.keepScreenOn(false) - disconnectPlayer() - } - init() - } - } - - private fun disconnectPlayer() { - if (this@PlaybackViewModel::player.isInitialized) { - player.removeListener(this@PlaybackViewModel) - (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) - + viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + player.addListener(this) + (player as? ExoPlayer)?.addAnalyticsListener(this) + addCloseable { player.removeListener(this@PlaybackViewModel) } + addCloseable { (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) } + addCloseable { this@PlaybackViewModel.activityListener?.let { it.release() player.removeListener(it) } - this@PlaybackViewModel.trackChangeListener?.let { - player.removeListener(it) - } - player.release() - mediaSession?.release() } - jobs.forEach { it.cancel() } - } - - private suspend fun createPlayer( - isHdr: Boolean, - is4k: Boolean, - ) { - val softwareDecoding = - !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding - val requestBackend = - (destination as? Destination.Playback)?.backend - ?: preferences.appPreferences.playbackPreferences.playerBackend - val playerBackend = - when (requestBackend) { - PlayerBackend.UNRECOGNIZED, - PlayerBackend.EXO_PLAYER, - -> PlayerBackend.EXO_PLAYER - - PlayerBackend.MPV -> PlayerBackend.MPV - - PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV - - PlayerBackend.EXTERNAL_PLAYER -> throw IllegalStateException("Cannot use this for external playback") - } - - Timber.d("Selected backend: %s", playerBackend) - if (currentPlayer.value?.backend != playerBackend) { - Timber.i("Switching player backend to %s", playerBackend) - withContext(WholphinDispatchers.Main) { - disconnectPlayer() - } - - val playerCreation = - playerFactory.createVideoPlayer( - playerBackend, - preferences.appPreferences, - ) - this.player = playerCreation.player - currentPlayer.update { - PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler) - } - configurePlayer() - } - } - - private fun configurePlayer() { - player.addListener(this) - (player as? ExoPlayer)?.addAnalyticsListener(this) - subscribeToWebSocket() - jobs.add(listenForTranscodeReason()) - val sessionPlayer = - MediaSessionPlayer( - player, - preferences.appPreferences.playbackPreferences, - ) - mediaSession = playerFactory.createMediaSession(sessionPlayer) + addCloseable { player.release() } + subscribe() + listenForTranscodeReason() } /** * Initialize from the UI to start playback */ - private suspend fun init() { - musicService.stop() - this.preferences = userPreferencesService.getCurrent() - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE - _state.update { - it.copy( - nextUp = null, - maxBitrate = maxBitrate, - ) - } - if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { - addCloseable { refreshRateService.resetRefreshRate() } - } + fun init( + destination: Destination, + deviceProfile: DeviceProfile, + preferences: UserPreferences, + ) { + nextUp.value = null + this.preferences = preferences controllerViewState.hideMilliseconds = preferences.appPreferences.playbackPreferences.controllerTimeoutMs + this.deviceProfile = deviceProfile this.forceTranscoding = (destination as? Destination.Playback)?.forceTranscoding ?: false val positionMs: Long + val itemPlayback: ItemPlayback? val forceTranscoding: Boolean val itemId = when (val d = destination) { is Destination.Playback -> { positionMs = d.positionMs + itemPlayback = d.itemPlayback forceTranscoding = d.forceTranscoding d.itemId } is Destination.PlaybackList -> { positionMs = 0 + itemPlayback = null forceTranscoding = false d.itemId } - else -> { - throw IllegalArgumentException("Destination not supported: $destination") - } + else -> throw IllegalArgumentException("Destination not supported: $destination") } this.itemId = itemId - val queriedItem = api.userLibraryApi.getItem(itemId).content - val playlistItem = - if (queriedItem.type.playable) { - PlaylistItem.Media(BaseItem(queriedItem, false)) - } else { - val playlistResult = - if (destination is Destination.PlaybackList) { + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error preparing for playback for $itemId", + ), + ) { + val queriedItem = + (destination as? Destination.Playback)?.item?.data + ?: api.userLibraryApi.getItem(itemId).content + val base = + if (queriedItem.type.playable) { + queriedItem + } else if (destination is Destination.PlaybackList) { + isPlaylist = true + val playlistResult = playlistCreator.createFrom( item = queriedItem, startIndex = destination.startIndex ?: 0, @@ -346,125 +237,74 @@ class PlaybackViewModel recursive = destination.recursive, filter = destination.filter, ) - } else { - val shuffled = - if (destination is Destination.Playback) { - destination.shuffle - } else { - false + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + return@launch + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return@launch } - // 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)) } - return - } - - is PlaylistCreationResult.Success -> { - if (r.playlist.items.isEmpty()) { - showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) - navigationManager.goBack() - return + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = r.playlist + } + r.playlist.items + .first() + .data } - _state.update { - it.copy(playlist = r.playlist) - } - r.playlist.items.first() } + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList") + } + val item = BaseItem.from(base, api) + + val played = + play( + item, + positionMs, + itemPlayback, + forceTranscoding, + ) + if (!played) { + playNextUp() + } + + if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { + val playlist = + playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = playlist } } - - viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - - val intros = - // If not resuming playback & cinema mode is enabled, get potential intros - if (positionMs == 0L && preferences.appPreferences.playbackPreferences.cinemaMode) { - api.userLibraryApi - .getIntros( - itemId = playlistItem.id, - userId = serverRepository.currentUser?.id, - ).content.items - .map { - PlaylistItem.Intro(BaseItem(it)) - } - } else { - emptyList() - } - val firstItem = - if (intros.isNotEmpty()) { - Timber.v("Got %s intros", intros.size) - _state.update { - it.copy(playlist = Playlist(intros + it.playlist.items)) - } - intros.first() - } else { - playlistItem - } - - val played = - play( - firstItem, - positionMs, - forceTranscoding, - ) - if (!played) { - playNextUp() - } - - if (!isPlaylist) { - val result = playlistCreator.createFrom(queriedItem) - if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - _state.update { - it.copy( - playlist = Playlist(it.playlist.items + result.playlist.items), - ) - } - } - } - } - - internal fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { - _state.update { - it.copy(currentPlayback = block.invoke(it.currentPlayback)) } } /** * Play an item * - * @param currentItem the item to play + * @param item the item to play * @param positionMs the starting playback position in milliseconds * @param itemPlayback the parameters for playback such chosen subtitle or audio streams * @param forceTranscoding whether the user has requested to force playback via transcoding */ private suspend fun play( - playlistItem: PlaylistItem, + item: BaseItem, positionMs: Long, + itemPlayback: ItemPlayback? = null, forceTranscoding: Boolean = this.forceTranscoding, ): Boolean = - withContext(WholphinDispatchers.IO) { - val item = - when (playlistItem) { - is PlaylistItem.Intro -> playlistItem.item - is PlaylistItem.Media -> playlistItem.item - } - + withContext(Dispatchers.IO) { Timber.i("Playing ${item.id}") + // Starting playback, so want to invalidate the last played timestamp for this item + datePlayedService.invalidate(item) // New item, so we can clear the media segment tracker & subtitle cues - resetSegmentState() - _state.update { it.copy(subtitleCues = emptyList()) } - - viewModelScope.launchIO { - // Starting playback, so want to invalidate the last played timestamp for this item - datePlayedService.invalidate(item) - } + autoSkippedSegments.clear() + this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) if (item.type !in supportItemKinds) { showToast( @@ -474,43 +314,28 @@ class PlaybackViewModel ) return@withContext false } - this@PlaybackViewModel.currentItem = playlistItem + this@PlaybackViewModel.item = item this@PlaybackViewModel.itemId = item.id val isLiveTv = item.type == BaseItemKind.TV_CHANNEL val base = item.data // Use the provided playback parameters or else check if the database has some - val itemPlayback = - serverRepository.currentUser?.let { user -> - itemPlaybackDao.getItem(user, base.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId != null) { - it - } else { - null + val playbackConfig = + itemPlayback + ?: serverRepository.currentUser.value?.let { user -> + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null + } } } - } - val mediaSource = - if (!isLiveTv) { - streamChoiceService.chooseSource(base, itemPlayback) - } else { - null - } + val mediaSource = chooseSource(base, playbackConfig) - val audioStc = - streamChoiceService.getSeriesTrackChoices( - base, - SeriesTrackChoiceType.AUDIO, - ) - val subtitleStc = - streamChoiceService.getSeriesTrackChoices( - base, - SeriesTrackChoiceType.AUDIO, - ) - - if (mediaSource == null && !isLiveTv) { + if (mediaSource == null) { showToast( context, "Item has no media sources, skipping...", @@ -519,108 +344,69 @@ class PlaybackViewModel return@withContext false } - val videoStream = - mediaSource - ?.mediaStreams - ?.firstOrNull { it.type == MediaStreamType.VIDEO } - ?.let { - val isHdr = - it.videoRange == VideoRange.HDR || - (it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN) - // Often times 4k movies have a wider aspect ratio so the height is lower even though the width is still 3840 - val is4k = (it.width ?: 0) > 2560 || (it.height ?: 0) > 1440 - SimpleVideoStream(it.index, isHdr, is4k) - } + val subtitleStreams = + mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + ?.map(SubtitleStream::from) + .orEmpty() + val audioStreams = + mediaSource.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map(AudioStream::from) + ?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels }) + .orEmpty() - // Create the correct player for the media - createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - - val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() - val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() - val audioResult = - mediaSource?.let { - streamChoiceService - .chooseAudioStream( - source = mediaSource, - item = base, - itemPlayback = itemPlayback, - stc = audioStc, - prefs = preferences, - ) - } - val audioIndex = audioResult?.stream?.index + val audioIndex = + chooseStream(base, playbackConfig, MediaStreamType.AUDIO, preferences) + ?.index val subtitleIndex = - mediaSource?.let { - streamChoiceService - .chooseSubtitleStream( - item = base, - source = mediaSource, - audioStream = audioResult?.stream, - itemPlayback = itemPlayback, - stc = subtitleStc, - prefs = preferences, - )?.stream - ?.index - } - Timber.d( - "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", - mediaSource?.id, - audioIndex, - subtitleIndex, - ) + chooseStream(base, playbackConfig, MediaStreamType.SUBTITLE, preferences) + ?.index + Timber.d("Selected mediaSource=${mediaSource.id}, audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + + val itemPlaybackToUse = + playbackConfig ?: 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 = - mediaSource?.id?.let { sourceId -> - item.data.trickplay - ?.get(sourceId) - ?.values - ?.firstOrNull() + item.data.trickplay + ?.get(mediaSource.id) + ?.values + ?.firstOrNull() + + val chapters = Chapter.fromDto(base, api) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse + updateCurrentMedia { + CurrentMediaInfo( + audioStreams = audioStreams, + subtitleStreams = subtitleStreams, + chapters = chapters, + trickPlayInfo = trickPlayInfo, + ) } - trickPlayInfo?.let { trickplayInfo -> - mediaSource.runTimeTicks?.ticks?.let { duration -> - viewModelScope.launchIO { - prefetchTrickplay( - duration, - trickplayInfo, - mediaSource.id?.toUUIDOrNull(), - ) - } - } - } - - val chapters = Chapter.fromDto(base) - _state.update { - it.copy( - currentItemPlayback = itemPlayback, - currentPlayback = null, - ) - } - updateCurrentMedia { - CurrentMediaInfo( - sourceId = mediaSource?.id, - videoStream = videoStream, - audioStreams = audioStreams, - subtitleStreams = subtitleStreams, - chapters = chapters, - trickPlayInfo = trickPlayInfo, - ) - } - withContext(WholphinDispatchers.Main) { changeStreams( - item = item, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - positionMs = if (positionMs > 0) positionMs else C.TIME_UNSET, - sourceId = mediaSource?.id, + 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 enableDirectPlay = !forceTranscoding, enableDirectStream = !forceTranscoding, ) player.prepare() player.play() } - listenForSegments(item.id) + listenForSegments() return@withContext true } @@ -630,547 +416,309 @@ class PlaybackViewModel @OptIn(UnstableApi::class) internal suspend fun changeStreams( item: BaseItem, - sourceId: String?, + currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = 0, + userInitiated: Boolean, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, - maxBitrate: Long = this.state.value.maxBitrate, - ): Unit = - withContext(WholphinDispatchers.IO) { - val itemId = item.id + ) = withContext(Dispatchers.IO) { + val itemId = item.id + val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend - trackChangeListener?.let { onMain { player.removeListener(it) } } - trackChangeListener = null - - state.value.currentPlayback?.let { currentPlayback -> - if (currentPlayback.item.id == item.id && - currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay && - state.value.maxBitrate == maxBitrate - ) { - val wasSuccessful = - changeStreamsDirectPlay( - currentPlayback = currentPlayback, - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - ) - if (wasSuccessful) return@withContext - } - } - - Timber.i( - "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", - itemId, - audioIndex, - subtitleIndex, - enableDirectPlay, - enableDirectStream, - positionMs, - maxBitrate, - ) - _state.update { it.copy(maxBitrate = maxBitrate) } - - 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 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, - liveStreamId = source.liveStreamId, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, - ) - } - } 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?", - ), - ) - } - 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 { - 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", - ) - - 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, - ) - } - } - } - 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 - * Except for external subtitles - */ - @OptIn(UnstableApi::class) - private suspend fun changeStreamsDirectPlay( - currentPlayback: CurrentPlayback, - audioIndex: Int?, - subtitleIndex: Int?, - ): Boolean = - withContext(WholphinDispatchers.IO) { + val currentPlayback = this@PlaybackViewModel.currentPlayback.value + if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { + // If direct playing, can try to switch tracks without playback restarting + // Except for external subtitles + // TODO there's probably no reason why we can't add external subtitles? 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(WholphinDispatchers.Main) { - TrackSelectionUtils.createTrackSelections( - onMain { player.trackSelectionParameters }, - onMain { player.currentTracks }, + withContext(Dispatchers.Main) { + TrackSelectionUtils.applyTrackSelections( + player, + playerBackend, + true, audioIndex, subtitleIndex, source, ) } if (result.bothSelected) { - onMain { player.trackSelectionParameters = result.trackSelectionParameters } + // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - _state.update { - it.copy( - currentPlayback = - (it.currentPlayback ?: currentPlayback).copy( - tracks = checkForSupport(onMain { player.currentTracks }), - ), + val itemPlayback = + currentItemPlayback.copy( + sourceId = source.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, ) + if (userInitiated) { + viewModelScope.launchIO { + Timber.v("Saving user initiated item playback: %s", itemPlayback) + val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = updated + } + } } - loadSubtitleDelay() - return@withContext true + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentPlayback.update { + (it ?: currentPlayback).copy( + tracks = checkForSupport(player.currentTracks), + ) + } + + this@PlaybackViewModel.currentItemPlayback.value = itemPlayback + } + + return@withContext } } else { Timber.v("changeStreams direct play, external subtitle was requested") } - return@withContext false } + 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 (playerBackend == PlayerBackend.EXO_PLAYER) { + deviceProfile + } 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) { + loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName)) + return@withContext + } + val source = response.mediaSources.firstOrNull() + source?.let { source -> + val mediaUrl = + if (source.supportsDirectPlay) { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) + } else if (source.supportsDirectStream) { + source.transcodingUrl?.let(api::createUrl) + } else { + source.transcodingUrl?.let(api::createUrl) + } + if (mediaUrl.isNullOrBlank()) { + loading.setValueOnMain( + LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"), + ) + 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.v("Playback decision: $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()) + .setUri(mediaUrl.toUri()) + .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) + .build() + + val playback = + CurrentPlayback( + item = item, + tracks = listOf(), + backend = preferences.appPreferences.playbackPreferences.playerBackend, + playMethod = transcodeType, + playSessionId = response.playSessionId, + liveStreamId = source.liveStreamId, + mediaSourceInfo = source, + ) + val itemPlayback = + currentItemPlayback.copy( + sourceId = source.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + ) + if (userInitiated) { + viewModelScope.launchIO { + Timber.v("Saving user initiated item playback: %s", itemPlayback) + val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = updated + } + } + } + + withContext(Dispatchers.Main) { + // TODO, don't need to release & recreate when switching streams + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + + val activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + playback = playback, + itemPlayback = itemPlayback, + ) + player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + + loading.value = LoadingState.Success + this@PlaybackViewModel.currentPlayback.update { playback } + this@PlaybackViewModel.currentItemPlayback.value = itemPlayback + player.setMediaItem( + mediaItem, + positionMs, + ) + 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.applyTrackSelections( + player, + playerBackend, + source.supportsDirectPlay, + audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, + subtitleIndex, + source, + ) + if (result.bothSelected) { + player.removeListener(this) + } + } + } + } + player.addListener(onTracksChangedListener) + } + } + } + } + fun changeAudioStream(index: Int) { viewModelScope.launchIO { - 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), - ) - } - - // Resolve ONLY_FORCED to actual track based on new audio language - val resolvedSubtitleIndex = - streamChoiceService.resolveSubtitleIndex( - source = currentPlayback.mediaSourceInfo, - audioStreamIndex = index, - item = currentItem.item.data, - 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 { - 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 resolvedIndex = - streamChoiceService.resolveSubtitleIndex( - source = currentPlayback.mediaSourceInfo, - audioStreamIndex = currentPlayback.audioIndex, - item = currentItem.item.data, - subtitleIndex = index, - prefs = preferences, - ) - - 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") - } - } - - fun changeMaxBitrate(newMaxBitrate: Long) { - viewModelScope.launchIO { - val currentPlayback = state.value.currentPlayback - if (currentPlayback != null) { - Timber.d("Changing bitrate to %s", newMaxBitrate) - changeStreams( - item = currentItem.item, - sourceId = currentPlayback.mediaSourceInfo.id, - audioIndex = currentPlayback.audioIndex, - subtitleIndex = currentPlayback.subtitleIndex, - positionMs = onMain { player.currentPosition }, - enableDirectPlay = true, - maxBitrate = newMaxBitrate, - ) - } else { - Timber.w("Trying to change bitrate, 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, - trickplayInfo: TrickplayInfo, - mediaSourceId: UUID?, - ) { - val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight - val totalCount = - (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1 - (0..<totalCount).forEach { - val url = getTrickplayUrl(it, trickplayInfo, mediaSourceId) - context.imageLoader.enqueue( - ImageRequest - .Builder(context) - .data(url) - .size(Size.ORIGINAL) - .build(), - ) - } - } - - fun getTrickplayUrl( - index: Int, - trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, - mediaSourceId: UUID? = - state.value.currentPlayback - ?.mediaSourceInfo - ?.id - ?.toUUIDOrNull(), - ): String? = - trickPlayInfo?.let { - val itemId = currentItem.id - return api.trickplayApi.getTrickplayTileImageUrl( - itemId, - trickPlayInfo.width, + changeStreams( + item, + currentItemPlayback.value!!, index, - mediaSourceId, + currentItemPlayback.value?.subtitleIndex, + onMain { player.currentPosition }, + true, ) } + } + + fun changeSubtitleStream(index: Int?): Job = + viewModelScope.launchIO { + changeStreams( + item, + currentItemPlayback.value!!, + currentItemPlayback.value?.audioIndex, + index, + onMain { player.currentPosition }, + true, + ) + } + + fun getTrickplayUrl(index: Int): String? { + val itemId = item.id + val mediaSourceId = currentItemPlayback.value?.sourceId + val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null + return api.trickplayApi.getTrickplayTileImageUrl( + itemId, + trickPlayInfo.width, + index, + mediaSourceId, + ) + } override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { - Timber.v("Playback state is STATE_ENDED") - viewModelScope.launchDefault { - when (val nextItem = state.value.nextItem()) { - is PlaylistItem.Intro -> { - Timber.v("Next item is intro, so playing immediately") - playNextUp() - } - - is PlaylistItem.Media -> { - 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() - } - } - } - - null -> { - Timber.v("No next up") - navigationManager.goBack() + viewModelScope.launchIO { + val nextItem = playlist.value?.peek() + Timber.v("Setting next up to ${nextItem?.id}") + withContext(Dispatchers.Main) { + nextUp.value = nextItem + if (nextItem == null) { + controllerViewState.showControls() } } } } } - // Variables for tracking segment state - @VisibleForTesting - internal var segmentJob: Job? = null - private val autoSkippedSegments = mutableSetOf<UUID>() - private val outroShownSegments = mutableSetOf<UUID>() - - /** - * Cancels listening for segments and clears current segment state - */ - private fun resetSegmentState() { - segmentJob?.cancel() - autoSkippedSegments.clear() - outroShownSegments.clear() - _state.update { it.copy(currentSegment = null) } - } + private var segmentJob: Job? = null /** * This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc) */ - private fun listenForSegments(itemId: UUID) { + private fun listenForSegments() { segmentJob?.cancel() segmentJob = viewModelScope.launchIO { @@ -1186,32 +734,23 @@ class PlaybackViewModel .firstOrNull { it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } - if (currentSegment != null && - currentSegment.itemId == this@PlaybackViewModel.itemId - ) { - if (currentSegment.id != - state.value.currentSegment - ?.segment - ?.id - ) { - Timber.d( - "Found media segment for %s: %s, %s", - currentSegment.itemId, - currentSegment.id, - currentSegment.type, - ) - } - val state = state.value + if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + currentSegment.id, + currentSegment.type, + ) + val playlist = this@PlaybackViewModel.playlist.value if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - state.hasNext && - outroShownSegments.add(currentSegment.id) + playlist != null && playlist.hasNext() ) { - 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) } + val nextItem = playlist.peek() + Timber.v("Setting next up during outro to ${nextItem?.id}") + withContext(Dispatchers.Main) { + nextUp.value = nextItem } } else { val behavior = @@ -1223,63 +762,41 @@ class PlaybackViewModel MediaSegmentType.INTRO -> prefs.skipIntros MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } - withContext(WholphinDispatchers.Main) { - val newSegment = - when (behavior) { - SkipSegmentBehavior.AUTO_SKIP -> { - if (autoSkippedSegments.add(currentSegment.id)) { - onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } - } - MediaSegmentState(currentSegment, true) - } - - SkipSegmentBehavior.ASK_TO_SKIP -> { - MediaSegmentState( - currentSegment, - autoSkippedSegments.contains(currentSegment.id), - ) - } - - else -> { - null - } + withContext(Dispatchers.Main) { + when (behavior) { + SkipSegmentBehavior.AUTO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = null + player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } - _state.update { it.copy(currentSegment = newSegment) } + + SkipSegmentBehavior.ASK_TO_SKIP -> { + this@PlaybackViewModel.currentSegment.value = + currentSegment + } + + else -> { + this@PlaybackViewModel.currentSegment.value = null + } + } } } } else if (currentSegment == null) { - _state.update { it.copy(currentSegment = null) } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentSegment.value = null + } } } } } } - fun updateSegment( - segmentId: UUID?, - dismissed: Boolean, - ) { - viewModelScope.launchDefault { - val segment = state.value.currentSegment?.segment - if (segment != null && segment.id == segmentId) { - autoSkippedSegments.add(segment.id) - if (dismissed) { - _state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) } - } else { - _state.update { it.copy(currentSegment = null) } - onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } - } - } - } - } - - private fun listenForTranscodeReason(): Job = + private fun listenForTranscodeReason() { viewModelScope.launchIO { - state.map { it.currentPlayback }.collectLatest { - if (it != null && it.playMethod == PlayMethod.TRANSCODE && it.transcodeInfo == null) { + currentPlayback.collectLatest { + if (it != null) { try { var transcodeInfo = it.transcodeInfo - while (isActive && transcodeInfo == null) { + while (isActive && it.playMethod == PlayMethod.TRANSCODE && transcodeInfo == null) { delay(2.seconds) transcodeInfo = api.sessionApi @@ -1290,13 +807,13 @@ class PlaybackViewModel if (transcodeInfo == null) delay(3.seconds) } Timber.v("transcodeInfo=$transcodeInfo") - updateCurrentPlayback { current -> + currentPlayback.update { current -> current?.copy(transcodeInfo = transcodeInfo) } } catch (ex: Exception) { if (ex !is CancellationException) { Timber.w(ex, "Exception trying to get session info") - updateCurrentPlayback { current -> + currentPlayback.update { current -> current?.copy(transcodeInfo = null) } } @@ -1304,6 +821,7 @@ class PlaybackViewModel } } } + } private var lastInteractionDate: Date = Date() @@ -1326,85 +844,57 @@ class PlaybackViewModel } fun playNextUp() { - viewModelScope.launchDefault { - playlistMutex.withLock { - val state = state.value - if (state.hasNext) { + playlist.value?.let { + if (it.hasNext()) { + viewModelScope.launchIO { cancelUpNextEpisode() - 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 + val item = it.getAndAdvance() + val played = play(item, 0) + if (!played) { + playNextUp() + } } } } } fun playPrevious() { - viewModelScope.launchDefault { - playlistMutex.withLock { - val state = state.value - if (state.hasPrevious) { + playlist.value?.let { + if (it.hasPrevious()) { + viewModelScope.launchIO { cancelUpNextEpisode() - 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 + val item = it.getPreviousAndReverse() + val played = play(item, 0) + if (!played) { + playPrevious() + } } } } } suspend fun cancelUpNextEpisode() { - _state.update { it.copy(nextUp = null) } + nextUp.setValueOnMain(null) } fun playItemInPlaylist(item: BaseItem) { - 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() - } - } + playlist.value?.let { playlist -> + viewModelScope.launchIO { + val toPlay = playlist.advanceTo(item.id) + if (toPlay != null) { + val played = play(toPlay, 0) + if (!played) { + playNextUp() + } } else { - Timber.w("Item not found in playlist %s", item.id) - return@launchDefault + // TODO } } } } override fun onTracksChanged(tracks: Tracks) { - updateCurrentPlayback { + currentPlayback.update { it?.copy( tracks = checkForSupport(tracks), ) @@ -1412,42 +902,35 @@ class PlaybackViewModel } override fun onCues(cueGroup: CueGroup) { - _state.update { it.copy(subtitleCues = cueGroup.cues) } + subtitleCues.value = cueGroup.cues } override fun onPlayerError(error: PlaybackException) { Timber.e(error, "Playback error") - viewModelScope.launch(WholphinDispatchers.Main + ExceptionHandler()) { - state.value.currentPlayback?.let { + viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + currentPlayback.value?.let { when (it.playMethod) { - PlayMethod.TRANSCODE -> { - _state.update { - it.copy( - loading = - LoadingState.Error( - "Error during playback", - error, - ), - ) - } - } + PlayMethod.TRANSCODE -> + loading.setValueOnMain( + LoadingState.Error( + "Error during playback", + error, + ), + ) PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") - val currentPlayback = state.value.currentPlayback - if (currentPlayback == null) { - Timber.w("Playback error, currentPlayback is null") - } changeStreams( - item = currentItem.item, - sourceId = currentPlayback?.mediaSourceInfo?.id, - audioIndex = currentPlayback?.audioIndex, - subtitleIndex = currentPlayback?.subtitleIndex, - positionMs = player.currentPosition, + item, + currentItemPlayback.value!!, + currentItemPlayback.value?.audioIndex, + currentItemPlayback.value?.subtitleIndex, + player.currentPosition, + false, enableDirectPlay = false, enableDirectStream = false, ) - withContext(WholphinDispatchers.Main) { + withContext(Dispatchers.Main) { player.prepare() player.play() } @@ -1458,128 +941,61 @@ class PlaybackViewModel } fun release() { - Timber.v("release") - disconnectPlayer() - activityListener = null + activityListener?.release() + player.release() } - fun subscribeToWebSocket() { - subscribeJob?.cancel() - try { - subscribeJob = - viewModelScope.launch { - coroutineScope { - api.webSocket - .subscribe<PlaystateMessage>() - .onEach { message -> - message.data?.let { request -> - Timber.v("Received playstate request: %s", request) - withContext(WholphinDispatchers.Main) { - handlePlaystateRequest(request) - } + fun subscribe() { + api.webSocket + .subscribe<PlaystateMessage>() + .onEach { message -> + message.data?.let { + withContext(Dispatchers.Main) { + when (it.command) { + PlaystateCommand.STOP -> { + release() + navigationManager.goBack() + } + + PlaystateCommand.PAUSE -> player.pause() + PlaystateCommand.UNPAUSE -> player.play() + PlaystateCommand.NEXT_TRACK -> playNextUp() + PlaystateCommand.PREVIOUS_TRACK -> playPrevious() + PlaystateCommand.SEEK -> + it.seekPositionTicks?.ticks?.let { + player.seekTo( + it.inWholeMilliseconds, + ) } - }.catch { ex -> - Timber.e(ex, "Error in websocket subscription") - }.launchIn(this) + + PlaystateCommand.REWIND -> + player.seekBack( + preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + ) + + PlaystateCommand.FAST_FORWARD -> + player.seekForward( + preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + ) + + PlaystateCommand.PLAY_PAUSE -> if (player.isPlaying) player.pause() else player.play() + } } } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error in playback websocket subscription") - if (viewModelScope.isActive) { - subscribeToWebSocket() - } - } + }.launchIn(viewModelScope) } - private suspend fun handlePlaystateRequest(request: PlaystateRequest) = - withContext(WholphinDispatchers.Main) { - when (request.command) { - PlaystateCommand.STOP -> { - release() - navigationManager.goBack() - } - - PlaystateCommand.PAUSE -> { - player.pause() - } - - PlaystateCommand.UNPAUSE -> { - player.play() - } - - PlaystateCommand.NEXT_TRACK -> { - playNextUp() - } - - PlaystateCommand.PREVIOUS_TRACK -> { - playPrevious() - } - - PlaystateCommand.SEEK -> { - request.seekPositionTicks?.ticks?.let { - player.seekTo(it.inWholeMilliseconds) - } - } - - PlaystateCommand.REWIND -> { - player.seekBack( - preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - ) - } - - PlaystateCommand.FAST_FORWARD -> { - player.seekForward( - preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - ) - } - - PlaystateCommand.PLAY_PAUSE -> { - Util.handlePlayPauseButtonAction(player) - } - } - } - /** * Atomically update [currentMediaInfo] */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = - withContext(WholphinDispatchers.Default) { - _state.update { - it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) + withContext(Dispatchers.IO) { + mutex.withLock { + val newMediaInfo = block.invoke(currentMediaInfo.value!!) + currentMediaInfo.setValueOnMain(newMediaInfo) } } - private fun updateDecoder( - decoderName: String, - type: MediaType, - ) { - viewModelScope.launchDefault { - val codecInfo = - MediaCodecList(MediaCodecList.ALL_CODECS) - .codecInfos - .firstOrNull { !it.isEncoder && it.name == decoderName } - val decoderString = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (codecInfo?.isHardwareAccelerated == true) { - "$decoderName (HW)" - } else { - decoderName - } - } else { - decoderName - } - updateCurrentPlayback { - when (type) { - MediaType.VIDEO -> it?.copy(videoDecoder = decoderString) - MediaType.AUDIO -> it?.copy(audioDecoder = decoderString) - else -> throw IllegalArgumentException("Unsupported type: $type") - } - } - } - } - override fun onVideoDecoderInitialized( eventTime: AnalyticsListener.EventTime, decoderName: String, @@ -1587,7 +1003,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.v("onVideoDecoderInitialized: decoder=$decoderName") - updateDecoder(decoderName, MediaType.VIDEO) + currentPlayback.update { it?.copy(videoDecoder = decoderName) } } override fun onVideoDisabled( @@ -1595,7 +1011,7 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("onVideoDisabled") - updateCurrentPlayback { it?.copy(videoDecoder = null) } + currentPlayback.update { it?.copy(videoDecoder = null) } } override fun onVideoInputFormatChanged( @@ -1606,7 +1022,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { Timber.d("onVideoInputFormatChanged: decoder=${decoder.decoderName}") - updateDecoder(decoder.decoderName, MediaType.VIDEO) + currentPlayback.update { it?.copy(videoDecoder = decoder.decoderName) } } } } @@ -1618,7 +1034,7 @@ class PlaybackViewModel initializationDurationMs: Long, ) { Timber.d("decoder: onAudioDecoderInitialized: decoder=$decoderName") - updateDecoder(decoderName, MediaType.AUDIO) + currentPlayback.update { it?.copy(audioDecoder = decoderName) } } override fun onAudioInputFormatChanged( @@ -1629,7 +1045,7 @@ class PlaybackViewModel decoderReuseEvaluation?.let { decoder -> if (decoder.result != DecoderReuseEvaluation.REUSE_RESULT_NO) { Timber.d("decoder: onAudioInputFormatChanged: decoder=${decoder.decoderName}") - updateDecoder(decoder.decoderName, MediaType.AUDIO) + currentPlayback.update { it?.copy(audioDecoder = decoder.decoderName) } } } } @@ -1639,124 +1055,6 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("decoder: onAudioDisabled") - updateCurrentPlayback { it?.copy(audioDecoder = null) } - } - - private var subtitleDelaySaveJob: Job? = null - - fun updateSubtitleDelay(delta: Duration) { - subtitleDelaySaveJob?.cancel() - updateCurrentPlayback { - it?.let { - val newDelay = it.subtitleDelay + delta - val result = it.copy(subtitleDelay = it.subtitleDelay + delta) - subtitleDelaySaveJob = - viewModelScope.launchIO { - // Debounce & save - state.value.currentItemPlayback?.let { item -> - delay(1500) - itemPlaybackRepository.saveTrackModifications( - item.itemId, - item.subtitleIndex, - newDelay, - ) - } - } - result - } - } - } - - suspend fun loadSubtitleDelay() { - state.value.currentPlayback?.let { - if (it.subtitleIndexEnabled) { - val result = - 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.item.id, - ) - updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } - } - } - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - screensaverService.keepScreenOn(isPlaying) - } - - override fun onAvailableCommandsChanged(availableCommands: Player.Commands) { - val player = this@PlaybackViewModel.player - if (availableCommands.contains(Player.COMMAND_PREPARE) && player is MpvPlayer) { - // MpvPlayer is initialized, so configure subtitles - Timber.i("Applying subtitle config to MPV") - viewModelScope.launchDefault { - val configuration = context.resources.configuration - val density = Density(context.resources.displayMetrics.density) - preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( - configuration, - density, - ) - } - } - } - - 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, - totalBytesLoaded: Long, - bitrateEstimate: Long, - ) { - Timber.v( - "onBandwidthEstimate: totalLoadTimeMs=%s, totalBytesLoaded=%s, bitrateEstimate=%s", - totalLoadTimeMs, - totalBytesLoaded, - bitrateEstimate, - ) - if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) { - _state.update { - it.copy( - analyticsState = - it.analyticsState.copy( - bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), - bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), - ), - ) - } - } - } - - override fun onDroppedVideoFrames( - eventTime: AnalyticsListener.EventTime, - droppedFrames: Int, - elapsedMs: Long, - ) { - _state.update { - it.copy( - analyticsState = - it.analyticsState.copy( - droppedFrames = it.analyticsState.droppedFrames + droppedFrames, - ), - ) - } + currentPlayback.update { it?.copy(audioDecoder = null) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt new file mode 100644 index 00000000..c966a9aa --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerLoadingState.kt @@ -0,0 +1,39 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.Player +import androidx.media3.common.listen +import androidx.media3.common.util.UnstableApi + +@UnstableApi +@Composable +fun rememberPlayerLoadingState(player: Player): PlayerLoadingState { + val state = remember(player) { PlayerLoadingState(player) } + LaunchedEffect(player) { + state.observe() + } + return state +} + +@UnstableApi +class PlayerLoadingState( + private val player: Player, +) : State<Boolean> { + override var value by mutableStateOf(player.isLoading) + private set + + suspend fun observe() { + value = player.isLoading + player.listen { + if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) { + value = player.isLoading + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt deleted file mode 100644 index 5b00aacc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.media3.common.Player -import androidx.media3.common.listenTo - -/** - * Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering. - * - * @see Player.State - * @see PlayerState - */ -@Composable -fun rememberPlayerState(player: Player): State<PlayerState> { - val state = remember(player) { mutableStateOf(getPlayerState(player.playbackState)) } - LaunchedEffect(player) { - player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) { - state.value = getPlayerState(player.playbackState) - } - } - return state -} - -private fun getPlayerState( - @Player.State value: Int, -): PlayerState = PlayerState.entries.first { it.value == value } - -/** - * Represents [Player.State] integers as an Enum - */ -enum class PlayerState( - @param:Player.State val value: Int, -) { - IDLE(Player.STATE_IDLE), - BUFFERING(Player.STATE_BUFFERING), - READY(Player.STATE_READY), - ENDED(Player.STATE_ENDED), -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt deleted file mode 100644 index 6925d5cc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 - -/** - * Shared seek acceleration profile for hold-to-seek behavior. - * Keep this in sync anywhere directional key repeat seeking is handled. - */ -fun calculateSeekAccelerationMultiplier( - repeatCount: Int, - durationMs: Long, -): Int { - if (repeatCount <= 0 || durationMs <= 0L) return 1 - - // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. - val scaledRepeatCount = repeatCount / 3 - if (scaledRepeatCount <= 0) return 1 - - val durationMinutes = durationMs / 60_000L - return when { - durationMinutes < 30 -> { - if (scaledRepeatCount < 30) 1 else 2 - } - - durationMinutes < 90 -> { - when { - scaledRepeatCount < 13 -> 1 - scaledRepeatCount < 50 -> 2 - scaledRepeatCount < 75 -> 3 - else -> 4 - } - } - - durationMinutes < 150 -> { - when { - scaledRepeatCount < 20 -> 1 - scaledRepeatCount < 40 -> 2 - scaledRepeatCount < 60 -> 4 - else -> 6 - } - } - - else -> { - when { - scaledRepeatCount < 20 -> 1 - scaledRepeatCount < 40 -> 3 - scaledRepeatCount < 60 -> 6 - else -> 10 - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt similarity index 55% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index d2aae720..d1db4144 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/SeekBar.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback.overlay +package com.github.damontecres.wholphin.ui.playback /* * Modified from https://github.com/android/tv-samples @@ -18,6 +18,7 @@ package com.github.damontecres.wholphin.ui.playback.overlay * limitations under the License. */ +import android.view.KeyEvent import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.focusable @@ -45,19 +46,10 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.playback.calculateSeekAccelerationMultiplier -import com.github.damontecres.wholphin.ui.playback.isDpadLeft -import com.github.damontecres.wholphin.ui.playback.isDpadRight +import com.github.damontecres.wholphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview -import timber.log.Timber import kotlin.time.Duration -/** - * This is a seek bar which seeks by a percentage of the duration instead of a fixed amount of time - * - * For example, if [intervals] is 10, then each seek will be 10% of total media duration - */ @Composable fun SteppedSeekBarImpl( progress: Float, @@ -88,16 +80,15 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - durationMs = durationMs, - onLeft = { multiplier -> + onLeft = { controllerViewState.pulseControls() - seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) + seekProgress = (progressToUse - offset).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { multiplier -> + onRight = { controllerViewState.pulseControls() - seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) + seekProgress = (progressToUse + offset).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -106,33 +97,6 @@ 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 - */ @OptIn(FlowPreview::class) @Composable fun IntervalSeekBarImpl( @@ -162,19 +126,16 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - durationMs = durationMs, - onLeft = { multiplier -> + onLeft = { controllerViewState.pulseControls() - seekPositionMs = - (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) + seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { multiplier -> + onRight = { controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds * multiplier) - .coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -183,19 +144,12 @@ fun IntervalSeekBarImpl( ) } -/** - * Actually renders the seek bar. It has callbacks for when the user moves to the left or right - * - * @see IntervalSeekBarImpl - * @see SteppedSeekBarImpl - */ @Composable -private fun SeekBarDisplay( +fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - durationMs: Long, - onLeft: (Int) -> Unit, - onRight: (Int) -> Unit, + onLeft: () -> Unit, + onRight: () -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -204,13 +158,14 @@ private fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() - var leftHandledByRepeat by remember { mutableStateOf(false) } - var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { Canvas( modifier = Modifier @@ -218,70 +173,24 @@ private fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - if (!isDpadLeft(event) && !isDpadRight(event)) { - Timber.v("Ignoring %s", event) - return@onPreviewKeyEvent false - } - if (isDpadLeft(event)) { - when (event.type) { - KeyEventType.KeyDown -> { - val repeatCount = event.nativeKeyEvent.repeatCount - if (repeatCount > 0) { - leftHandledByRepeat = true - onLeft.invoke( - calculateSeekAccelerationMultiplier( - repeatCount = repeatCount, - durationMs = durationMs, - ), - ) - } else { - leftHandledByRepeat = false - } - } - - KeyEventType.KeyUp -> { - if (!leftHandledByRepeat) { - onLeft.invoke(1) - } - leftHandledByRepeat = false - } - - else -> { - return@onPreviewKeyEvent false - } + val trigger = + event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 + when (event.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { + if (trigger) onLeft.invoke() + return@onPreviewKeyEvent true } - return@onPreviewKeyEvent true - } else { - when (event.type) { - KeyEventType.KeyDown -> { - val repeatCount = event.nativeKeyEvent.repeatCount - if (repeatCount > 0) { - rightHandledByRepeat = true - onRight.invoke( - calculateSeekAccelerationMultiplier( - repeatCount = repeatCount, - durationMs = durationMs, - ), - ) - } else { - rightHandledByRepeat = false - } - } - KeyEventType.KeyUp -> { - if (!rightHandledByRepeat) { - onRight.invoke(1) - } - rightHandledByRepeat = false - } - - else -> { - return@onPreviewKeyEvent false - } + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { + if (trigger) onRight.invoke() + return@onPreviewKeyEvent true } - return@onPreviewKeyEvent true } - }.focusable(enabled = enabled, interactionSource = interactionSource), + false + }.handleDPadKeyEvents( + onLeft = onLeft, + onRight = onRight, + ).focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( @@ -307,6 +216,7 @@ private fun SeekBarDisplay( start = Offset(x = 0f, y = yOffset), end = Offset( +// x = size.width.times(if (isSelected) seekProgress else progress), x = size.width.times(progress), y = yOffset, ), @@ -316,11 +226,7 @@ private fun SeekBarDisplay( drawCircle( color = Color.White, radius = size.height + 2, - center = - Offset( - x = size.width.times(progress), - y = yOffset, - ), + center = Offset(x = size.width.times(progress), y = yOffset), ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt similarity index 96% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt index 16e95d15..3de5dca7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBarState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBarState.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback.overlay +package com.github.damontecres.wholphin.ui.playback import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt new file mode 100644 index 00000000..1f060b15 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekPreviewImage.kt @@ -0,0 +1,123 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import coil3.compose.AsyncImage +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.transformations +import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import org.jellyfin.sdk.model.api.TrickplayInfo + +fun Modifier.offsetByPercent( + xPercentage: Float, + yOffset: Int, +) = this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = + ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) + .coerceIn(0, constraints.maxWidth - placeable.width), + y = constraints.maxHeight - yOffset, // (constraints.maxHeight * yPercentage).toInt() - (placeable.height / 1.33f).toInt(), + ) + } + }, +) + +/** + * Offset the composable by a percentage of the available x direction + * + * This will account for the composable actual width so it won't be pushed off screen. + * In other words, 0% means the left edge of the composable will be at the left end of the x-axis. + * + * @param xPercentage percent offset between 0 inclusive and 1 inclusive + */ +fun Modifier.offsetByPercent(xPercentage: Float) = + this.then( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = + ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) + .coerceIn(0, constraints.maxWidth - placeable.width), + y = 0, + ) + } + }, + ) + +/** + * Show trickplay preview image. This composable assumes the provided URL is for the correct index. + * + * If no trickplay image is available, just the timestamp will be shown. + */ +@Composable +fun SeekPreviewImage( + previewImageUrl: String, + duration: Long, + seekProgressMs: Long, + videoWidth: Int?, + videoHeight: Int?, + trickPlayInfo: TrickplayInfo, + modifier: Modifier = Modifier, + placeHolder: Painter? = null, +) { + val context = LocalContext.current + + if (previewImageUrl.isNotNullOrBlank() && + videoWidth != null && + videoHeight != null + ) { + val height = 160.dp + val width = height * (videoWidth.toFloat() / videoHeight) + val heightPx = with(LocalDensity.current) { height.toPx().toInt() } + val widthPx = with(LocalDensity.current) { width.toPx().toInt() } + + val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile + val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth + val imageIndex = index % numberOfTitlesPerImage + + AsyncImage( + modifier = + modifier + .width(width) + .height(height) + .background(Color.Black) + .border(1.5.dp, color = MaterialTheme.colorScheme.border), + model = + ImageRequest + .Builder(context) + .data(previewImageUrl) + .memoryCachePolicy(CachePolicy.ENABLED) + .transformations( + CoilTrickplayTransformation( + widthPx, + heightPx, + trickPlayInfo.tileHeight, + trickPlayInfo.tileWidth, + imageIndex, + index, + ), + ).build(), + contentScale = ContentScale.None, + contentDescription = null, + placeholder = placeHolder, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt deleted file mode 100644 index 04699005..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import android.content.res.Resources -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle -import org.jellyfin.sdk.model.api.MediaStream - -/** - * A slimmer [MediaStream] with minimal data for UI purposes - * - * @see SimpleVideoStream - */ -data class SimpleMediaStream( - val index: Int, - val streamTitle: String?, - val displayTitle: String, -) { - companion object { - fun from( - resources: Resources, - mediaStream: MediaStream, - includeFlags: Boolean = true, - ): SimpleMediaStream = - SimpleMediaStream( - index = mediaStream.index, - streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, - displayTitle = mediaStreamDisplayTitle(resources, mediaStream, includeFlags), - ) - } -} - -data class SimpleVideoStream( - val index: Int, - val hdr: Boolean, - val is4k: Boolean, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt similarity index 91% rename from app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt index 3ac39bfa..0a3e343d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SkipIndicator.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.playback.overlay +package com.github.damontecres.wholphin.ui.playback import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -25,9 +25,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.ifElse import kotlin.math.abs -/** - * Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs]. - */ @Composable fun SkipIndicator( durationMs: Long, @@ -64,7 +61,6 @@ fun SkipIndicator( modifier = Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.onBackground, fontSize = 13.sp, - style = MaterialTheme.typography.bodySmall, text = abs(durationMs / 1000).toString(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt deleted file mode 100644 index 8c645c9b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelay.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -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.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.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.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds) - -/** - * Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay - */ -@Composable -fun SubtitleDelay( - delay: Duration, - onChangeDelay: (Duration) -> Unit, - modifier: Modifier = Modifier, -) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.subtitle_delay) + ": " + delay.toString(), - color = MaterialTheme.colorScheme.onSurface, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - delayIncrements.reversed().forEach { - SubtitleDelayButton( - text = "-$it", - onClick = { onChangeDelay.invoke(-it) }, - modifier = Modifier, - ) - } - SubtitleDelayButton( - text = stringResource(R.string.reset), - onClick = { onChangeDelay.invoke(-delay) }, - modifier = Modifier.focusRequester(focusRequester), - ) - delayIncrements.forEach { - SubtitleDelayButton( - text = "+$it", - onClick = { onChangeDelay.invoke(it) }, - modifier = Modifier, - ) - } - } - } -} - -@Composable -fun SubtitleDelayButton( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - modifier = modifier.width(64.dp), - shape = - ClickableSurfaceDefaults.shape( - shape = RoundedCornerShape(33), - ), - ) { - Text( - text = text, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@PreviewTvSpec -@Composable -private fun SubtitleDelayPreview() { - WholphinTheme { - SubtitleDelay( - delay = 1.5.seconds, - onChangeDelay = {}, - modifier = Modifier, - ) - } -} 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 c08759c0..4ee2ded3 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 @@ -5,143 +5,103 @@ import androidx.compose.ui.text.intl.Locale import androidx.lifecycle.viewModelScope 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.data.model.chooseSource import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi 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 +sealed interface SubtitleSearch { + data object Searching : SubtitleSearch - data object Searching : SubtitleSearchStatus - - data object Downloading : SubtitleSearchStatus + data object Downloading : SubtitleSearch data class Success( val options: List<RemoteSubtitleInfo>, - ) : SubtitleSearchStatus + ) : SubtitleSearch data class Error( val message: String?, val ex: Exception?, - ) : SubtitleSearchStatus + ) : SubtitleSearch } -/** - * Trigger a search for subtitles in the given language for the currently playing media - */ -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, - language = language, - ) - } +fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { + subtitleSearch.value = SubtitleSearch.Searching + subtitleSearchLanguage.value = language viewModelScope.launchIO { try { - state.value.currentPlayback?.itemId?.let { itemId -> - Timber.v("Searching for remote subtitles for %s", itemId) + currentItemPlayback.value?.itemId?.let { + Timber.v("Searching for remote subtitles for %s", it) val results = api.subtitleApi .searchRemoteSubtitles( - itemId = itemId, + itemId = it, language = language, ).content .sortedWith( - compareByDescending<RemoteSubtitleInfo> { it.isHashMatch } - .thenByDescending { it.communityRating } + compareByDescending<RemoteSubtitleInfo> { it.communityRating } .thenByDescending { it.downloadCount }, ) - subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } + subtitleSearch.setValueOnMain(SubtitleSearch.Success(results)) } } catch (ex: Exception) { Timber.e(ex, "Exception while searching for subtitles") - subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) } + subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) } } } -/** - * Download the remote subtitles and attempt to activate them once complete - */ fun PlaybackViewModel.downloadAndSwitchSubtitles( subtitleId: String?, wasPlaying: Boolean, ) { if (subtitleId == null) { - subtitleSearchState.update { - it.copy( - status = - SubtitleSearchStatus.Error( - "Subtitle has no ID", - null, - ), - ) - } + subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null) } else { - subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } + subtitleSearch.value = SubtitleSearch.Downloading viewModelScope.launchIO { try { - state.value.currentPlayback?.let { currentPlayback -> + currentItemPlayback.value?.let { Timber.v( "Downloading remote subtitles for itemId=%s, sourceId=%s: %s", - currentPlayback.itemId, - currentPlayback.sourceId, + it.itemId, + it.sourceId, subtitleId, ) api.subtitleApi.downloadRemoteSubtitles( - itemId = currentPlayback.sourceId ?: currentPlayback.itemId, + itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) val currentSubtitleStreams = - currentPlayback.mediaSourceInfo.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } + this@downloadAndSwitchSubtitles + .currentMediaInfo.value + ?.subtitleStreams .orEmpty() - val externalPaths = currentSubtitleStreams.map { it.path } - val subtitleCount = currentSubtitleStreams.size var newCount = subtitleCount var maxAttempts = 4 + var newStreams: List<SubtitleStream> = listOf() - var mediaSource: MediaSourceInfo? = null // 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.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 = - base.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == currentPlayback.sourceId } + delay(1500) + item = + BaseItem.from( + api.userLibraryApi.getItem(itemId = it.itemId).content, + api, + ) + val mediaSource = chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -157,6 +117,26 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() newCount = subtitleStreams.size + + if (subtitleCount != newCount) { + newStreams = + subtitleStreams.map { + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + it.displayTitle, + ) + } + updateCurrentMedia { + it.copy(subtitleStreams = newStreams) + } + } } if (maxAttempts == 0) { showToast( @@ -165,55 +145,32 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } else { // Find the new subtitle stream - val subtitlesStreams = - mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE } val newStream = - subtitlesStreams?.firstOrNull { stream -> - stream.isExternal && stream.path !in externalPaths - } + newStreams + .toMutableList() + .apply { + removeAll(currentSubtitleStreams) + }.firstOrNull { it.external } if (newStream != null) { - 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 + var audioIndex = currentItemPlayback.value?.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 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( - 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( - item = currentItem.item, - sourceId = currentPlayback.mediaSourceInfo.id, - audioIndex = audioIndex, - subtitleIndex = newStream.index, - positionMs = onMain { player.currentPosition }, - enableDirectPlay = true, + item, + currentItemPlayback.value!!, + audioIndex, + newStream.index, + onMain { player.currentPosition }, + true, ) } } - subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } - withContext(WholphinDispatchers.Main) { + subtitleSearch.setValueOnMain(null) + withContext(Dispatchers.Main) { if (wasPlaying) { player.play() } @@ -221,20 +178,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } catch (ex: Exception) { Timber.e(ex, "Exception while downloading subtitles: $subtitleId") - subtitleSearchState.update { - it.copy( - status = - SubtitleSearchStatus.Error( - null, - ex, - ), - ) - } + subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex)) } } } } fun PlaybackViewModel.cancelSubtitleSearch() { - subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } + subtitleSearch.value = null } 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 7b377399..9bfde0ed 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 @@ -3,177 +3,182 @@ package com.github.damontecres.wholphin.ui.playback import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format +import androidx.media3.common.Player 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.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.preferences.PlayerBackend 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 -/** - * Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player] - */ object TrackSelectionUtils { @OptIn(UnstableApi::class) - fun createTrackSelections( - trackSelectionParams: TrackSelectionParameters, - tracks: Tracks, + fun applyTrackSelections( + player: Player, + playerBackend: PlayerBackend, + supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val paramsBuilder = trackSelectionParams.buildUpon() + val videoStreamCount = source.videoStreamCount + val audioStreamCount = source.audioStreamCount + val embeddedSubtitleCount = source.embeddedSubtitleCount + val externalSubtitleCount = source.externalSubtitlesCount + + val paramsBuilder = player.trackSelectionParameters.buildUpon() + val tracks = player.currentTracks.groups + val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { val subtitleIsExternal = source.findExternalSubtitle(subtitleIndex) != null - val chosenTrack = - if (subtitleIsExternal) { - tracks.groups.firstOrNull { group -> - group.type == C.TRACK_TYPE_TEXT && group.isExternal - } - } 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) + if (subtitleIsExternal || supportsDirectPlay) { + val chosenTrack = + if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { + tracks.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 { - null + val indexToFind = + calculateIndexToFind( + subtitleIndex, + MediaStreamType.SUBTITLE, + playerBackend, + videoStreamCount, + audioStreamCount, + embeddedSubtitleCount, + externalSubtitleCount, + subtitleIsExternal, + ) + Timber.v("Chosen subtitle ($subtitleIndex/$indexToFind) track") + // subtitleIndex - externalSubtitleCount + 1 + tracks.firstOrNull { group -> + group.type == C.TRACK_TYPE_TEXT && group.isSupported && + (0..<group.mediaTrackGroup.length) + .map { + group.getTrackFormat(it).idAsInt + }.contains(indexToFind) + } } - } - when { - chosenTrack != null && chosenTrack.isSupported -> { + + Timber.v("Chosen subtitle ($subtitleIndex) track: $chosenTrack") + chosenTrack?.let { 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) - TrackSelected.SELECTED - } + true + } val audioSelected = - if (audioIndex != null && audioIndex >= 0) { - val playerIndex = getPlayerIndex(audioIndex, source, MediaStreamType.AUDIO) + if (audioIndex != null && supportsDirectPlay) { + val indexToFind = + calculateIndexToFind( + audioIndex, + MediaStreamType.AUDIO, + playerBackend, + videoStreamCount, + audioStreamCount, + embeddedSubtitleCount, + externalSubtitleCount, + false, + ) val chosenTrack = - 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 + tracks.firstOrNull { group -> + group.type == C.TRACK_TYPE_AUDIO && group.isSupported && + (0..<group.mediaTrackGroup.length) + .map { + group.getTrackFormat(it).idAsInt + }.contains(indexToFind) } + 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 { - TrackSelected.SELECTED + audioIndex == null } - return TrackSelectionResult(paramsBuilder.build(), audioSelected, subtitleSelected) + if (audioSelected && subtitleSelected) { + player.trackSelectionParameters = paramsBuilder.build() + } + return TrackSelectionResult(audioSelected, subtitleSelected) } - private fun getPlayerIndex( + /** + * Maps the server provided index to the track index based on the [PlayerBackend] and other stream information + */ + private fun calculateIndexToFind( serverIndex: Int, - source: MediaSourceInfo, 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 + playerBackend: PlayerBackend, + videoStreamCount: Int, + audioStreamCount: Int, + embeddedSubtitleCount: Int, + externalSubtitleCount: Int, + subtitleIsExternal: Boolean, + ): Int = + when (playerBackend) { + PlayerBackend.EXO_PLAYER, + PlayerBackend.UNRECOGNIZED, + -> { + serverIndex - externalSubtitleCount + 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) + // TODO MPV could use literal indexes because they are stored in the track format ID + PlayerBackend.MPV -> { + when (type) { + MediaStreamType.VIDEO -> serverIndex - externalSubtitleCount + 1 + MediaStreamType.AUDIO -> serverIndex - externalSubtitleCount - videoStreamCount + 1 + MediaStreamType.SUBTITLE -> { + if (subtitleIsExternal) { + serverIndex + embeddedSubtitleCount + 1 } else { - track to listOf(it[0], Int.MAX_VALUE) + serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 } } + else -> throw UnsupportedOperationException("Cannot calculate index for $type") + } } - }.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 */ @@ -214,29 +219,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = subtitleIndex?.let { firstOrNull { - it.type == MediaStreamType.SUBTITLE && - (it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) && + it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.index == subtitleIndex } } -/** - * The result of [TrackSelectionUtils.createTrackSelections] - */ data class TrackSelectionResult( - val trackSelectionParameters: TrackSelectionParameters, - val audio: TrackSelected, - val subtitle: TrackSelected, + val audioSelected: Boolean, + val subtitleSelected: Boolean, ) { - 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, + val bothSelected: Boolean = audioSelected && subtitleSelected } 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 deleted file mode 100644 index c5192153..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TracksChangedListener.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index fa32950b..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/ChapterRowOverlay.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -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.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -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.media3.common.Player -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.ui.cards.ChapterCard -import com.github.damontecres.wholphin.ui.components.HiddenFocusBox -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.tryRequestFocus -import kotlin.time.Duration.Companion.milliseconds - -@Composable -fun ChapterRowOverlay( - player: Player, - controllerViewState: ControllerViewState, - chapters: List<Chapter>, - hasNext: Boolean, - onChangeState: (OverlayViewState) -> Unit, - modifier: Modifier = Modifier, -) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - val chapterInteractionSources = - remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } } - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val chapterIndex = - remember { - val position = player.currentPosition.milliseconds - val index = - chapters - .indexOfFirst { it.position > position } - .minus(1) - .let { - if (it < 0) { - // Didn't find a chapter, so it's either the first or last - if (position < chapters.first().position) { - 0 - } else { - chapters.lastIndex - } - } else { - it - } - }.coerceIn(0, chapters.lastIndex) - index - } - val listState = rememberLazyListState(chapterIndex) - val focusRequester = remember { FocusRequester() } - val hiddenFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - bringIntoViewRequester.bringIntoView() - focusRequester.tryRequestFocus() - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - HiddenFocusBox(hiddenFocusRequester) { - onChangeState.invoke(OverlayViewState.CONTROLLER) - } - Text( - text = stringResource(R.string.chapters), - style = MaterialTheme.typography.titleLarge, - ) - LazyRow( - state = listState, - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(focusRequester) - .onFocusChanged { - if (it.hasFocus) { - controllerViewState.pulseControls() - } - }.focusProperties { - up = hiddenFocusRequester - }, - ) { - itemsIndexed(chapters) { index, chapter -> - val interactionSource = chapterInteractionSources[index] - val isFocused = - interactionSource.collectIsFocusedAsState().value - LaunchedEffect(isFocused) { - if (isFocused) controllerViewState.pulseControls() - } - ChapterCard( - chapter = chapter, - onClick = { - player.seekTo(chapter.position.inWholeMilliseconds) - controllerViewState.hideControls() - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - index == chapterIndex, - Modifier - .focusRequester(focusRequester) - .bringIntoViewRequester( - bringIntoViewRequester, - ), - ).ifElse( - index == 0, - Modifier.focusProperties { - // Prevent scrolling left on first card to prevent moving down - left = - if (isLtr) FocusRequester.Cancel else FocusRequester.Default - right = - if (isLtr) FocusRequester.Default else FocusRequester.Cancel - }, - ), - ) - } - } - if (hasNext) { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(bottom = 8.dp) - .onFocusChanged { - if (it.isFocused) onChangeState.invoke(OverlayViewState.QUEUE) - }.focusable(), - ) - } - } -} 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 deleted file mode 100644 index 0a3ed634..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt +++ /dev/null @@ -1,71 +0,0 @@ -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/PauseIndicator.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt deleted file mode 100644 index 8414d5c5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PauseIndicator.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.compose.state.observeState -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.R -import kotlinx.coroutines.delay -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * Show an animated "pause" image whenever the player is paused - */ -@Composable -fun PauseIndicator( - player: Player, - modifier: Modifier = Modifier, - duration: Duration = 300.milliseconds, -) { - val state = rememberPauseState(player) - var visible by remember { mutableStateOf(false) } - LaunchedEffect(state.isPaused) { - if (state.isPaused) visible = true - } - AnimatedVisibility( - visible = visible, - enter = - scaleIn( - animationSpec = - tween( - durationMillis = duration.inWholeMilliseconds.toInt(), - ), - ), - exit = fadeOut(spring(stiffness = Spring.StiffnessMediumLow)), - modifier = modifier, - ) { - LaunchedEffect(Unit) { - delay(duration) - delay(50) - visible = false - } - Icon( - modifier = Modifier.size(64.dp, 64.dp), - painter = painterResource(id = R.drawable.baseline_pause_24), - tint = MaterialTheme.colorScheme.onSurface, - contentDescription = null, - ) - } -} - -/** - * Remember when a player is paused - */ -@Composable -fun rememberPauseState(player: Player): PauseState { - val state = remember(player) { PauseState(player) } - LaunchedEffect(player) { state.observe() } - return state -} - -class PauseState( - private val player: Player, -) { - var isPaused by mutableStateOf(false) - private set - - @OptIn(UnstableApi::class) - internal suspend fun observe() { - player - .observeState( - Player.EVENT_PLAYBACK_STATE_CHANGED, - Player.EVENT_PLAY_WHEN_READY_CHANGED, - Player.EVENT_AVAILABLE_COMMANDS_CHANGED, - ) { - // Timber.v("isPaused=$isPaused, playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}") - isPaused = !isPaused && // Not already paused, don't want to trigger more than once - !player.playWhenReady && // Player is actually paused - // Player could play if it was not paused, ie it is not stopped - player.playbackState.let { it == Player.STATE_READY || it == Player.STATE_BUFFERING } - }.observe() - } -} 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 deleted file mode 100644 index 2909e666..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackController.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -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.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.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 -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.media3.common.Player -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.formatTime -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import org.jellyfin.sdk.model.api.MediaSegmentDto -import java.time.LocalTime -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -@Composable -fun PlaybackController( - item: BaseItem?, - nextState: OverlayViewState?, - player: Player, - controllerViewState: ControllerViewState, - showPlay: Boolean, - previousEnabled: Boolean, - nextEnabled: Boolean, - seekEnabled: Boolean, - seekBack: Duration, - skipBackOnResume: Duration?, - seekForward: Duration, - onPlaybackActionClick: (PlaybackAction) -> Unit, - onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - onSeekBarChange: (Long) -> Unit, - currentSegment: MediaSegmentDto?, - onChangeState: (OverlayViewState) -> Unit, - modifier: Modifier = Modifier, - seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Controller( - title = item?.title, - subtitle = item?.subtitleLong, - player = player, - controllerViewState = controllerViewState, - showPlay = showPlay, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBack = seekBack, - skipBackOnResume = skipBackOnResume, - seekForward = seekForward, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = onClickPlaybackDialogType, - onSeekProgress = onSeekBarChange, - seekBarInteractionSource = seekBarInteractionSource, - currentSegment = currentSegment, - modifier = Modifier, - ) - when (nextState) { - OverlayViewState.CHAPTERS -> { - Text( - text = stringResource(R.string.chapters), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(start = 16.dp, top = 0.dp) - .onFocusChanged { - if (it.isFocused) onChangeState.invoke(nextState) - }.focusable(), - ) - } - - OverlayViewState.QUEUE -> { - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - modifier = - Modifier - .padding(start = 16.dp, top = 0.dp) - .onFocusChanged { - if (it.isFocused) onChangeState.invoke(nextState) - }.focusable(), - ) - } - - else -> { - Unit - } - } - } -} - -internal val titleTextSize = 28.sp -internal val subtitleTextSize = 18.sp - -/** - * A wrapper for the playback controls to show title and other information, plus the actual controls - * - * @see PlaybackControls - */ -@Composable -fun Controller( - title: String?, - player: Player, - controllerViewState: ControllerViewState, - showPlay: Boolean, - previousEnabled: Boolean, - nextEnabled: Boolean, - seekEnabled: Boolean, - seekBack: Duration, - skipBackOnResume: Duration?, - seekForward: Duration, - onPlaybackActionClick: (PlaybackAction) -> Unit, - onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - onSeekProgress: (Long) -> Unit, - currentSegment: MediaSegmentDto?, - modifier: Modifier = Modifier, - subtitle: String? = null, - seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() - val verticalOffset by animateDpAsState( - targetValue = if (seekBarFocused) (-32).dp else 0.dp, - label = "TitleBumpOffset", - animationSpec = - spring( - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Dp.VisibilityThreshold, - ), - ) - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(start = 16.dp) - .offset(y = verticalOffset), - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - fontSize = titleTextSize, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.End), - ) { - subtitle?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - fontSize = subtitleTextSize, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } - - val context = LocalContext.current - var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(player, context) { - while (isActive) { - val remaining = - (player.duration - player.currentPosition) - .div(player.playbackParameters.speed) - .toLong() - .milliseconds - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = formatTime(context, endTime) - delay(1.seconds) - } - } - Text( - text = stringResource(R.string.ends_at, endTimeStr), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) - } - } - PlaybackControls( - modifier = Modifier.fillMaxWidth(), - player = player, - onPlaybackActionClick = onPlaybackActionClick, - controllerViewState = controllerViewState, - onSeekProgress = { - onSeekProgress(it) - }, - showPlay = showPlay, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBarInteractionSource = seekBarInteractionSource, - seekBarIntervals = 16, - seekBack = seekBack, - seekForward = seekForward, - skipBackOnResume = skipBackOnResume, - currentSegment = currentSegment, - onClickPlaybackDialogType = onClickPlaybackDialogType, - ) - } -} 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 deleted file mode 100644 index 2aecdc65..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackDebugOverlay.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import android.content.Context -import android.hardware.display.DisplayManager -import android.view.Display -import androidx.annotation.OptIn -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -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 androidx.compose.ui.unit.sp -import androidx.media3.common.Format -import androidx.media3.common.util.UnstableApi -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ProvideTextStyle -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.formatBitrate -import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.letNotEmpty -import com.github.damontecres.wholphin.ui.playback.AnalyticsState -import com.github.damontecres.wholphin.ui.playback.CurrentPlayback -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 com.github.damontecres.wholphin.util.WholphinDispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.withContext -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.HardwareAccelerationType -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.MediaStreamProtocol -import org.jellyfin.sdk.model.api.PlayMethod -import org.jellyfin.sdk.model.api.TranscodeReason -import org.jellyfin.sdk.model.api.TranscodingInfo -import timber.log.Timber -import java.util.Locale -import kotlin.time.Duration.Companion.seconds - -@Composable -fun PlaybackDebugOverlay( - analyticsState: AnalyticsState, - currentPlayback: CurrentPlayback?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val display = - remember(context) { - try { - val displayManager = - context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager - displayManager?.getDisplay(Display.DEFAULT_DISPLAY) - } catch (ex: Exception) { - Timber.e(ex) - null - } - } - val displayMode by produceState<String?>(null) { - while (isActive) { - value = - display?.mode?.let { - val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate) - "${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}" - } - delay(10.seconds) - } - } - val textStyle = - MaterialTheme.typography.bodySmall.copy( - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurface, - ) - - val memoryUsed by produceState("") { - withContext(WholphinDispatchers.Default) { - while (isActive) { - val runtime = Runtime.getRuntime() - val total = runtime.totalMemory() - val free = runtime.freeMemory() - val used = total - free - val totalMemory = formatBytes(total) - val usedMemory = formatBytes(used) - value = "$usedMemory / $totalMemory" - delay(2.seconds) - } - } - } - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 8.dp, top = 8.dp), - ) { - ProvideTextStyle(textStyle) { - SimpleTable( - remember(currentPlayback, displayMode) { - buildList { - add("Backend:" to currentPlayback?.backend?.toString()) - add("Play method:" to currentPlayback?.playMethod?.serialName) - if (currentPlayback?.backend == PlayerBackend.EXO_PLAYER) { - add("Video Decoder:" to currentPlayback.videoDecoder) - add("Audio Decoder:" to (currentPlayback.audioDecoder ?: "Non-ExoPlayer")) - } - add("Display Mode: " to displayMode) - } - }, - modifier = Modifier.weight(1f, fill = false), - keyWidth = 80.dp, - ) - SimpleTable( - rows = - listOf( - "Bitrate: " to analyticsState.bitrate, - "Bitrate (est): " to analyticsState.bitrateEstimate, - "Dropped frames: " to analyticsState.droppedFrames, - "Memory" to memoryUsed, - ), - keyWidth = 80.dp, - modifier = Modifier.weight(1f, fill = false), - ) - currentPlayback?.transcodeInfo?.let { info -> - SimpleTable( - listOf( - "Reason:" to info.transcodeReasons.joinToString(", "), - "HW Accel:" to info.hardwareAccelerationType?.toString(), - "Container:" to info.container, - "Bitrate:" to info.bitrate?.let { formatBitrate(it) }, - ), - modifier = Modifier.weight(1f, fill = false), - keyWidth = 64.dp, - ) - SimpleTable( - listOf( - "Video:" to "${info.videoCodec}, ${info.width}x${info.height}", - "Video Direct:" to info.isVideoDirect.toString(), - "Audio:" to "${info.audioCodec}, ch=${info.audioChannels}", - "Audio Direct:" to info.isAudioDirect.toString(), - ), - modifier = Modifier.weight(1f, fill = false), - keyWidth = 64.dp, - ) - } - } - } - currentPlayback?.tracks?.letNotEmpty { - PlaybackTrackInfo( - trackSupport = it, - textStyle = textStyle, - ) - } - } -} - -@Composable -fun SimpleTable( - rows: List<Pair<String, Any?>>, - modifier: Modifier = Modifier, - keyWidth: Dp = 100.dp, -) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - rows.forEach { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = it.first, - modifier = Modifier.width(keyWidth), - ) - Text( - text = it.second.toString(), - modifier = Modifier, - ) - } - } - } -} - -@OptIn(UnstableApi::class) -@PreviewTvSpec -@Composable -fun PlaybackDebugOverlayPreview() { - val currentPlayback = - CurrentPlayback( - item = - BaseItem( - data = - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.EPISODE, - ), - ), - backend = PlayerBackend.EXO_PLAYER, - playMethod = PlayMethod.TRANSCODE, - playSessionId = "123", - liveStreamId = "123", - videoDecoder = "video.decoder.name", - audioDecoder = null, // "audio.decoder.name", - mediaSourceInfo = - MediaSourceInfo( - protocol = MediaProtocol.HTTP, - type = MediaSourceType.DEFAULT, - isRemote = false, - readAtNativeFramerate = true, - ignoreDts = true, - ignoreIndex = true, - genPtsInput = false, - supportsTranscoding = true, - supportsDirectStream = true, - supportsDirectPlay = true, - isInfiniteStream = false, - requiresOpening = false, - requiresClosing = false, - requiresLooping = false, - supportsProbing = true, - transcodingSubProtocol = MediaStreamProtocol.HTTP, - hasSegments = false, - ), - transcodeInfo = - TranscodingInfo( - videoCodec = "h264", - audioCodec = "aac", - container = "HLS", - width = 1080, - height = 1920, - isVideoDirect = false, - isAudioDirect = false, - transcodeReasons = - listOf( - TranscodeReason.VIDEO_PROFILE_NOT_SUPPORTED, - TranscodeReason.AUDIO_CHANNELS_NOT_SUPPORTED, - ), - hardwareAccelerationType = HardwareAccelerationType.NONE, - ), - tracks = - listOf( - TrackSupport( - id = "0", - type = TrackType.VIDEO, - supported = TrackSupportReason.EXCEEDS_CAPABILITIES, - selected = true, - labels = listOf(), - codecs = "avc1", - format = Format.Builder().build(), - ), - TrackSupport( - id = "0", - type = TrackType.AUDIO, - supported = TrackSupportReason.HANDLED, - selected = true, - labels = listOf(), - codecs = "ac3", - format = Format.Builder().build(), - ), - ) + - List(10) { - TrackSupport( - id = "0", - type = TrackType.TEXT, - supported = TrackSupportReason.HANDLED, - selected = false, - labels = listOf(), - codecs = "srt", - format = Format.Builder().build(), - ) - }, - ) - WholphinTheme { - PlaybackDebugOverlay( - analyticsState = AnalyticsState(), - currentPlayback = currentPlayback, - modifier = Modifier.fillMaxSize(), - ) - } -} 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 deleted file mode 100644 index d00fefea..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ /dev/null @@ -1,376 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.spring -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideIn -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOut -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -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.fillMaxSize -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.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -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.onFocusChanged -import androidx.compose.ui.graphics.Brush -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 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.PlaylistItem -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.TimeDisplay -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.playback.AnalyticsState -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.playback.CurrentPlayback -import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType -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 - -/** - * The overlay during playback showing controls, seek preview image, debug info, etc - */ -@Composable -fun PlaybackOverlay( - item: BaseItem?, - chapters: List<Chapter>, - player: Player, - controllerViewState: ControllerViewState, - showPlay: Boolean, - showClock: Boolean, - previousEnabled: Boolean, - nextEnabled: Boolean, - seekEnabled: Boolean, - seekBack: Duration, - skipBackOnResume: Duration?, - seekForward: Duration, - onPlaybackActionClick: (PlaybackAction) -> Unit, - onClickPlaybackDialogType: (PlaybackDialogType) -> Unit, - onSeekBarChange: (Long) -> Unit, - showDebugInfo: Boolean, - currentPlayback: CurrentPlayback?, - currentSegment: MediaSegmentDto?, - analyticsState: AnalyticsState, - queue: List<PlaylistItem>, - modifier: Modifier = Modifier, - trickplayInfo: TrickplayInfo? = null, - trickplayUrlFor: (Int) -> String? = { null }, - onClickPlaylist: (BaseItem) -> Unit = {}, - seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() - // Will be used for preview/trick play images - var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(player.currentPosition) } - var seekProgressPercent = (seekProgressMs.toDouble() / player.duration).toFloat() - - val density = LocalDensity.current - - val titleHeight = - remember(item?.title, density) { - if (item?.title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp - } - val subtitleHeight = - remember(item?.subtitleLong, density) { - if (item?.subtitleLong.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp - } - - // This will be calculated after composition - var controllerHeight by remember { mutableStateOf(0.dp) } - var state by remember(controllerViewState.controlsVisible) { - mutableStateOf(if (controllerViewState.controlsVisible) OverlayViewState.CONTROLLER else OverlayViewState.HIDDEN) - } - val onChangeState = { newState: OverlayViewState -> - state = newState - } - - Box( - modifier = modifier, - contentAlignment = Alignment.BottomCenter, - ) { - AnimatedVisibility( - visible = controllerViewState.controlsVisible && !showDebugInfo, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.matchParentSize(), - ) { - // Background scrim for OSD readability - val scrimBrush = - remember { - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.5f), - Color.Black.copy(alpha = 0.80f), - ), - ) - } - Box( - modifier = - Modifier - .fillMaxSize() - .background(scrimBrush), - ) - } - - AnimatedContent( - targetState = state, - label = "controls transition", - transitionSpec = { - if (targetState.ordinal > initialState.ordinal) { - // Moving down, so move content up - (slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut()) - } else { - // Moving up - (slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut()) - } - }, - ) { targetState -> - when (targetState) { - OverlayViewState.HIDDEN -> { - // Necessary so the bounds for animation are full width - Box(Modifier.fillMaxWidth()) - } - - OverlayViewState.CONTROLLER -> { - if (seekBarFocused) { - LaunchedEffect(Unit) { - seekProgressPercent = - (player.currentPosition.toFloat() / player.duration) - } - } - val nextState = - remember(chapters, nextEnabled) { - if (chapters.isNotEmpty()) { - OverlayViewState.CHAPTERS - } else if (nextEnabled) { - OverlayViewState.QUEUE - } else { - null - } - } - PlaybackController( - item = item, - nextState = nextState, - player = player, - controllerViewState = controllerViewState, - showPlay = showPlay, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBack = seekBack, - skipBackOnResume = skipBackOnResume, - seekForward = seekForward, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = onClickPlaybackDialogType, - onSeekBarChange = { - onSeekBarChange(it) - seekProgressMs = it - }, - currentSegment = currentSegment, - onChangeState = onChangeState, - modifier = - Modifier - .padding(bottom = 8.dp) - .onGloballyPositioned { - controllerHeight = with(density) { it.size.height.toDp() } - }, - seekBarInteractionSource = seekBarInteractionSource, - ) - } - - OverlayViewState.CHAPTERS -> { - if (chapters.isNotEmpty()) { - ChapterRowOverlay( - player = player, - controllerViewState = controllerViewState, - chapters = chapters, - hasNext = nextEnabled, - onChangeState = onChangeState, - modifier = - Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - ) - } - } - - OverlayViewState.QUEUE -> { - if (nextEnabled) { - QueueRowOverlay( - queue = queue, - controllerViewState = controllerViewState, - nextState = - remember(chapters) { - if (chapters.isNotEmpty()) { - OverlayViewState.CHAPTERS - } else { - OverlayViewState.CONTROLLER - } - }, - onChangeState = onChangeState, - onClickPlaylist = onClickPlaylist, - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth(), - ) - } - } - } - } - - 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(), - ) { - Box( - modifier = - Modifier - .align(Alignment.Center) - .fillMaxWidth(.95f), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .align(Alignment.BottomStart) - .offsetByPercent( - xPercentage = seekProgressPercent.coerceIn(0f, 1f), - ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), - ) { - TrickplayPreview( - seekProgressMs = seekProgressMs, - trickplayInfo = trickplayInfo, - trickplayUrlFor = trickplayUrlFor, - ) - } - } - } - } - - // Top - val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) - AnimatedVisibility( - visible = !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, - enter = slideIn { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeIn(), - exit = slideOut { IntOffset(x = -it.width / 2, y = -it.height / 2) } + fadeOut(), - modifier = - Modifier - .align(Alignment.TopStart), - ) { - AsyncImage( - model = logoImageUrl, - contentDescription = "Logo", - alignment = Alignment.TopStart, - modifier = - Modifier - .size(width = 240.dp, height = 120.dp) - .padding(16.dp), - ) - } - AnimatedVisibility( - visible = !showDebugInfo && showClock && controllerViewState.controlsVisible, - enter = slideIn { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeIn(), - exit = slideOut { IntOffset(x = it.width / 2, y = -it.height / 2) } + fadeOut(), - modifier = - Modifier - .align(Alignment.TopEnd), - ) { - TimeDisplay() - } - - // Debug overlay - AnimatedVisibility( - visible = showDebugInfo && controllerViewState.controlsVisible, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), - modifier = - Modifier - .align(Alignment.TopStart), - ) { - val configuration = LocalConfiguration.current - val height = - remember( - configuration, - controllerHeight, - ) { configuration.screenHeightDp.dp - controllerHeight } - PlaybackDebugOverlay( - analyticsState = analyticsState, - currentPlayback = currentPlayback, - modifier = - Modifier - .align(Alignment.TopStart) - .heightIn(max = height) - .padding(bottom = 16.dp) - .background(AppColors.TransparentBlack50) - .padding(8.dp) - .onFocusChanged { - if (it.hasFocus) { - // If Debug overlay gains focus, do not hide the controls - controllerViewState.pulseControls(Long.MAX_VALUE) - } else { - controllerViewState.pulseControls() - } - }, - ) - } - } -} - -/** - * The view state of the overlay - */ -enum class OverlayViewState { - HIDDEN, - CONTROLLER, - CHAPTERS, - QUEUE, -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt deleted file mode 100644 index e48fbb97..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackTrackInfo.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -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.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -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.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ProvideTextStyle -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.util.TrackSupport - -/** - * Debug info about the current playback tracks - */ -@Composable -fun PlaybackTrackInfo( - trackSupport: List<TrackSupport>, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface), -) { - val selectedTracks = remember(trackSupport) { trackSupport.filter { it.selected } } - val selectedWeight = .5f - val weights = listOf(.25f, .4f, .5f, 1f, 1f) - - var expanded by remember { mutableStateOf(false) } - val focusRequesters = - remember(trackSupport.size) { List(trackSupport.size) { FocusRequester() } } - val showButtonFocusRequester = remember { FocusRequester() } - - Column( - modifier = modifier, - verticalArrangement = Arrangement.SpaceBetween, - ) { - LazyColumn( - modifier = Modifier.weight(1f, fill = false), - ) { - item { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - val texts = - listOf( - "ID", - "Type", - "Codec", - "Supported", - "Labels", - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.weight(selectedWeight), - ) { - Text( - text = "Selected", - style = textStyle, - ) - } - texts.forEachIndexed { index, text -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.weight(weights[index]), - ) { - Text( - text = text, - style = textStyle, - ) - } - } - } - } - val tracks = if (expanded) trackSupport else selectedTracks - itemsIndexed(tracks) { index, track -> - ProvideTextStyle(textStyle) { - TrackSupportRow( - track = track, - selectedWeight = selectedWeight, - weights = weights, - modifier = - Modifier - .focusRequester(focusRequesters[index]) - .ifElse( - index == tracks.lastIndex, - Modifier.focusProperties { - down = showButtonFocusRequester - right = showButtonFocusRequester - }, - ), - ) - } - } - } - if (trackSupport.size > selectedTracks.size) { - val density = LocalDensity.current - val height = - remember(density, textStyle) { - with(density) { - textStyle.fontSize.toDp() * 2 - } - } - Box(Modifier.fillMaxWidth()) { - Button( - onClick = { expanded = !expanded }, - shape = - ClickableSurfaceDefaults.shape( - shape = RoundedCornerShape(25), - ), - contentHeight = height, - modifier = - Modifier - .align(Alignment.CenterEnd) - .focusRequester(showButtonFocusRequester) - .focusProperties { - up = focusRequesters[0] - }, - ) { - val text = - if (expanded) { - stringResource(R.string.hide) - } else { - stringResource( - R.string.show_more, - trackSupport.size - selectedTracks.size, - ) - } - Text( - text = text, - fontSize = textStyle.fontSize, - ) - } - } - } - } -} - -@Composable -fun TrackSupportRow( - track: TrackSupport, - selectedWeight: Float, - weights: List<Float>, - modifier: Modifier = Modifier, -) { - Surface( - onClick = {}, - modifier = modifier, - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), - shape = ClickableSurfaceDefaults.shape(RectangleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - focusedContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = .25f), - ), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - Modifier.ifElse( - track.selected, - Modifier.background(MaterialTheme.colorScheme.border.copy(alpha = .25f)), - ), - ) { - val texts = - listOf( - track.id ?: "", - track.type.name, - track.codecs ?: "", - track.supported.name, - track.labels.joinToString(", "), - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.weight(selectedWeight), - ) { - if (track.selected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.size(12.dp), - ) - } else { - Text( - text = "-", - ) - } - } - texts.forEachIndexed { index, text -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier.weight(weights[index]), - ) { - Text( - text = text, - ) - } - } - } - } -} 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 deleted file mode 100644 index 92b7afd1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/QueueRowOverlay.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -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.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.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.data.model.BaseItem -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 -import com.github.damontecres.wholphin.ui.playback.ControllerViewState -import com.github.damontecres.wholphin.ui.tryRequestFocus - -@Composable -fun QueueRowOverlay( - queue: List<PlaylistItem>, - controllerViewState: ControllerViewState, - nextState: OverlayViewState, - onChangeState: (OverlayViewState) -> Unit, - onClickPlaylist: (BaseItem) -> Unit, - modifier: Modifier = Modifier, -) { - val focusRequester = remember { FocusRequester() } - val hiddenFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - HiddenFocusBox(hiddenFocusRequester) { - onChangeState.invoke(nextState) - } - Text( - text = stringResource(R.string.queue), - style = MaterialTheme.typography.titleLarge, - ) - LazyRow( - contentPadding = - PaddingValues( - horizontal = 16.dp, - vertical = 8.dp, - ), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(focusRequester) - .onFocusChanged { - if (it.hasFocus) { - controllerViewState.pulseControls() - } - }, - ) { - itemsIndexed(queue) { index, item -> - val interactionSource = - remember { MutableInteractionSource() } - val isFocused = - interactionSource.collectIsFocusedAsState().value - LaunchedEffect(isFocused) { - if (isFocused) controllerViewState.pulseControls() - } - SeasonCard( - item = item.item, - onClick = { - onClickPlaylist.invoke(item.item) - controllerViewState.hideControls() - }, - onLongClick = {}, - imageHeight = 140.dp, - interactionSource = interactionSource, - modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt deleted file mode 100644 index 35600342..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekPreviewImage.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.annotation.FloatRange -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -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.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.drawscope.scale -import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp -import androidx.tv.material3.MaterialTheme -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import org.jellyfin.sdk.model.api.TrickplayInfo - -fun Modifier.offsetByPercent( - xPercentage: Float, - yOffset: Int, -) = this.then( - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative( - x = - ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) - .coerceIn(0, constraints.maxWidth - placeable.width), - y = constraints.maxHeight - yOffset, // (constraints.maxHeight * yPercentage).toInt() - (placeable.height / 1.33f).toInt(), - ) - } - }, -) - -/** - * Offset the composable by a percentage of the available x direction - * - * This will account for the composable actual width so it won't be pushed off screen. - * In other words, 0% means the left edge of the composable will be at the left end of the x-axis. - * - * @param xPercentage percent offset between 0 inclusive and 1 inclusive - */ -fun Modifier.offsetByPercent( - @FloatRange(0.0, 1.0) xPercentage: Float, -) = this.then( - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative( - x = - ((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2) - .coerceIn(0, constraints.maxWidth - placeable.width), - y = 0, - ) - } - }, -) - -/** - * Show trickplay preview image. This composable assumes the provided URL is for the correct index. - * - * If no trickplay image is available, just the timestamp will be shown. - */ -@Composable -fun SeekPreviewImage( - previewImageUrl: String, - seekProgressMs: Long, - trickPlayInfo: TrickplayInfo, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - - if (previewImageUrl.isNotNullOrBlank()) { - val height = 160.dp - val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height) - val scale = with(LocalDensity.current) { width.toPx() / trickPlayInfo.width } - - val model = - remember(previewImageUrl) { - ImageRequest - .Builder(context) - .data(previewImageUrl) - .size(coil3.size.Size.ORIGINAL) - .build() - } - val painter = - rememberAsyncImagePainter( - model = model, - contentScale = ContentScale.None, - ) - val index = - (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Index of tile across images - val numberOfTilesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth - val tileIndex = - index % numberOfTilesPerImage // Index of tile within the current image - val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid - val y = (tileIndex / trickPlayInfo.tileWidth) // y position - Box( - modifier = - modifier - .border(1.5.dp, color = MaterialTheme.colorScheme.border) - .background(Color.Black) - .height(height) - .width(width), - ) { - Canvas( - modifier = - Modifier - .height(height) - .width(width) - .clip(RectangleShape), - ) { - with(painter) { - // Scale and translate to the right position in the tile grid - scale(scale, scale, pivot = Offset.Zero) { - translate( - left = -x.toFloat() * trickPlayInfo.width, - top = -y.toFloat() * trickPlayInfo.height, - ) { - draw( - size = - Size( - trickPlayInfo.width * trickPlayInfo.tileWidth.toFloat(), - trickPlayInfo.height * trickPlayInfo.tileHeight.toFloat(), - ), - ) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt deleted file mode 100644 index f4a14e26..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SkipSegmentButton.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback.overlay - -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.InfiniteRepeatableSpec -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -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.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.skipStringRes -import com.github.damontecres.wholphin.ui.theme.PreviewInteractionSource -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import org.jellyfin.sdk.model.api.MediaSegmentType - -@Composable -fun SkipSegmentButton( - type: MediaSegmentType, - onClick: () -> Unit, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - interactionSource: MutableInteractionSource? = null, -) { - val infiniteTransition = rememberInfiniteTransition("SkipSegmentButton") - val color by infiniteTransition.animateColor( - initialValue = MaterialTheme.colorScheme.onSurface, - targetValue = Color.Transparent, - animationSpec = - InfiniteRepeatableSpec( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "SkipSegmentButton_color", - ) - Button( - onClick = onClick, - modifier = - modifier.drawBehind { - val px = 6.dp.toPx() - drawRoundRect( - color = color, - cornerRadius = CornerRadius(this.size.width * .5f), - topLeft = Offset(-px, -px), - size = Size(this.size.width + px * 2, this.size.height + px * 2), - ) - }, - onLongClick = onLongClick, - enabled = true, - contentPadding = - PaddingValues( - start = 8.dp, - top = 4.dp, - end = 8.dp, - bottom = 4.dp, - ), - contentHeight = 32.dp, - interactionSource = interactionSource, - content = { - Text(text = stringResource(type.skipStringRes)) - }, - ) -} - -@PreviewTvSpec -@Composable -fun SkipSegmentButtonPreview() { - WholphinTheme { - val source = remember { PreviewInteractionSource() } - SkipSegmentButton( - type = MediaSegmentType.INTRO, - onClick = {}, - modifier = Modifier.padding(16.dp), - interactionSource = source, - ) - } -} 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 deleted file mode 100644 index 4871a77a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt +++ /dev/null @@ -1,63 +0,0 @@ -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/ChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt deleted file mode 100644 index b21479c8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.tv.material3.Text -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.SelectedLeadingContent - -@Composable -fun <T> ChoicePreference( - title: String, - summary: String?, - possibleValues: List<T>, - selectedIndex: Int, - onValueChange: (Int) -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, - subtitleDisplay: (index: Int, item: T) -> @Composable (() -> Unit)? = { _, _ -> null }, -) { - var dialogParams by remember { mutableStateOf<DialogParams?>(null) } - ClickPreference( - title = title, - summary = summary, - onClick = { - dialogParams = - DialogParams( - title = title, - fromLongClick = false, - items = - possibleValues.mapIndexed { index, item -> - DialogItem( - headlineContent = { valueDisplay.invoke(index, item) }, - leadingContent = { - SelectedLeadingContent(index == selectedIndex) - }, - supportingContent = subtitleDisplay.invoke(index, item), - onClick = { - onValueChange.invoke(index) - dialogParams = null - }, - ) - }, - ) - }, - interactionSource = interactionSource, - modifier = modifier, - ) - AnimatedVisibility(dialogParams != null) { - dialogParams?.let { - DialogPopup( - showDialog = true, - title = it.title, - dialogItems = it.items, - onDismissRequest = { dialogParams = null }, - waitToLoad = false, - dismissOnClick = false, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 4a7338e2..b19d9390 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -1,8 +1,19 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue @@ -11,6 +22,7 @@ import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -18,12 +30,13 @@ import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle 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.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference import com.github.damontecres.wholphin.preferences.AppDestinationPreference @@ -32,24 +45,24 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppStringPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference +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.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch -import java.io.File +import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable fun <T> ComposablePreference( - preference: AppPreference<*, T>, + preference: AppPreference<T>, value: T?, onValueChange: (T) -> Unit, onNavigate: (Destination) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onClickPreference: (AppClickablePreference<*>) -> Unit = {}, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -59,6 +72,13 @@ fun <T> ComposablePreference( val title = stringResource(preference.title) + val onClick: () -> Unit = { + scope.launch(ExceptionHandler()) { + when (preference) { + else -> {} + } + } + } val onLongClick: () -> Unit = { scope.launch(ExceptionHandler()) { when (preference) { @@ -68,38 +88,7 @@ fun <T> ComposablePreference( } when (preference) { - AppPreference.MpvConfFile -> { - ClickPreference( - title = title, - onClick = { - val confFile = File(context.filesDir, "mpv.conf") - val contents = if (confFile.exists()) confFile.readText() else "" - showStringDialog = - StringInput( - title = title, - value = contents, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.None, - ), - onSubmit = { - confFile.writeText(it) - showStringDialog = null - }, - maxLines = 10, - confirmDiscard = true, - ) - }, - summary = preference.summary(context, value), - interactionSource = interactionSource, - modifier = modifier, - ) - } - - is AppDestinationPreference -> { + is AppDestinationPreference -> ClickPreference( title = title, onClick = { @@ -109,20 +98,18 @@ fun <T> ComposablePreference( interactionSource = interactionSource, modifier = modifier, ) - } - is AppClickablePreference -> { + is AppClickablePreference -> ClickPreference( title = title, - onClick = { onClickPreference.invoke(preference) }, + onClick = onClick, onLongClick = onLongClick, summary = preference.summary(context, value), interactionSource = interactionSource, modifier = modifier, ) - } - is AppSwitchPreference -> { + is AppSwitchPreference -> SwitchPreference( title = title, value = value as Boolean, @@ -131,9 +118,8 @@ fun <T> ComposablePreference( interactionSource = interactionSource, modifier = modifier, ) - } - is AppStringPreference -> { + is AppStringPreference -> ClickPreference( title = title, onClick = { @@ -164,12 +150,9 @@ fun <T> ComposablePreference( interactionSource = interactionSource, modifier = modifier, ) - } is AppChoicePreference -> { val values = stringArrayResource(preference.displayValues).toList() - val subtitles = - preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() } val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -177,29 +160,44 @@ fun <T> ComposablePreference( .valueToIndex(value as T) .let { values[it] } val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) } - ChoicePreference( + ClickPreference( title = title, summary = summary, - possibleValues = values, - selectedIndex = selectedIndex, - onValueChange = { index -> - onValueChange(preference.indexToValue(index)) + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + values.mapIndexed { index, it -> + if (index == selectedIndex) { + DialogItem( + text = it, + icon = Icons.Default.Done, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) + } else { + DialogItem( + text = it, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) + } + }, + ) }, - modifier = modifier, interactionSource = interactionSource, - subtitleDisplay = { index, _ -> - subtitles?.getOrNull(index)?.takeIf { it.isNotNullOrBlank() }?.let { - { - Text(text = it) - } - } - }, + modifier = modifier, ) } - is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues) - val subtitles = preference.displayValuesSubtitles?.let { stringArrayResource(it) } + is AppMultiChoicePreference<*> -> { + val values = stringArrayResource(preference.displayValues).toSortedSet() val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -210,18 +208,12 @@ fun <T> ComposablePreference( list } MultiChoicePreference( - possibleValues = preference.allValues, + possibleValues = values as SortedSet<Any>, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(it.toList() as T) - }, - valueDisplay = { index, _ -> - Text(values[index]) - }, - subtitleDisplay = { index, _ -> - subtitles?.getOrNull(index)?.let { Text(it) } + onValueChange.invoke(selectedValues.toList() as T) }, ) } @@ -258,15 +250,73 @@ fun <T> ComposablePreference( } } AnimatedVisibility(showStringDialog != null) { - showStringDialog?.let { input -> - StringInputDialog( - input = input, - onSave = { - input.onSubmit.invoke(it) - }, + showStringDialog?.let { + var mutableValue by remember { mutableStateOf(it.value ?: "") } + val onDone = { + it.onSubmit.invoke(mutableValue) + } + Dialog( onDismissRequest = { showStringDialog = null }, - elevation = 3.dp, - ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(16.dp) + .background( + color = dialogBackgroundColor, + shape = RoundedCornerShape(8.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = it.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier, + ) + EditTextBox( + value = mutableValue, + onValueChange = { mutableValue = it }, + keyboardOptions = it.keyboardOptions.copy(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { onDone.invoke() }, + ), + leadingIcon = null, + isInputValid = { true }, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth(), + ) { + Button( + onClick = { showStringDialog = null }, + modifier = Modifier, + ) { + Text( + text = stringResource(R.string.cancel), + ) + } + Button( + onClick = onDone, + modifier = Modifier, + ) { + Text( + text = stringResource(R.string.save), + ) + } + } + } + } + } } } } @@ -309,11 +359,9 @@ fun PreferenceSummary( } } -data class StringInput( +private data class StringInput( val title: String, val value: String?, val keyboardOptions: KeyboardOptions, val onSubmit: (String) -> Unit, - val maxLines: Int = 1, - val confirmDiscard: Boolean = false, ) 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 deleted file mode 100644 index b352c550..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt +++ /dev/null @@ -1,194 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import android.content.Context -import android.content.res.XmlResourceParser -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.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import 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.viewModelScope -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.ui.components.DialogItem -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.launchDefault -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.LoadingState -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.collectLatest -import kotlinx.coroutines.flow.update -import timber.log.Timber -import java.util.Locale -import javax.inject.Inject - -@HiltViewModel -class LocaleChoiceViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val serverRepository: ServerRepository, - ) : ViewModel() { - fun changeLocale(locale: Locale) { - viewModelScope.launchDefault { - serverRepository.current.value?.let { - Timber.i("Updating user's locale to %s", locale.toLanguageTag()) - val newUser = it.user.copy(uiLanguage = locale.toLanguageTag()) - serverRepository.changeUser(it.server, newUser) - } - } - } - - private val _state = MutableStateFlow(LocaleChoiceState()) - val state: StateFlow<LocaleChoiceState> = _state - - init { - viewModelScope.launchDefault { - serverRepository.currentUser?.let { - val availableLocales = extractAvailableLocales() - Timber.v("availableLocales=%s", availableLocales) - _state.update { - it.copy( - loading = LoadingState.Success, - availableLocales = availableLocales, - ) - } - } - } - viewModelScope.launchDefault { - serverRepository.currentUserFlow.collectLatest { user -> - val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() - _state.update { it.copy(userLocale = userLocale) } - } - } - } - - /** - * Get the available locales that can be used. - * - * Defaults to `context.assets.locales` (all locales) if can't find app specific ones - */ - private fun extractAvailableLocales(): List<Locale> { - val id = - context.resources.getIdentifier( - "_generated_res_locale_config", - "xml", - context.packageName, - ) - return if (id != 0) { - try { - val locales = mutableListOf<Locale>() - // This is kind of a hack, the generated locale config is not available, - // programmatically, so this code parses the raw XML - context.resources.getXml(id).use { parser -> - var eventType: Int = parser.eventType - while (eventType != XmlResourceParser.END_DOCUMENT) { - if (eventType == XmlResourceParser.START_TAG) { - val tagName: String? = parser.name - - // Check for a specific tag name - if ("locale" == tagName) { - val attr = - parser.getAttributeValue( - "http://schemas.android.com/apk/res/android", - "name", - ) - attr?.let { locales.add(Locale.forLanguageTag(it)) } - } - } - eventType = parser.next() - } - } - locales - } catch (ex: Exception) { - Timber.w(ex, "Exception while parsing generated available locales") - context.assets.locales.map { Locale.forLanguageTag(it) } - } - } else { - context.assets.locales.map { Locale.forLanguageTag(it) } - } - } - } - -data class LocaleChoiceState( - val loading: LoadingState = LoadingState.Pending, - val availableLocales: List<Locale> = emptyList(), - val userLocale: Locale = Locale.getDefault(), -) - -@Composable -fun LocaleChoiceDialog( - onDismissRequest: () -> Unit, - viewModel: LocaleChoiceViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsState() - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(), - ) { - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage() - } - - LoadingState.Success -> { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - val dialogItems = - remember(state.userLocale, state.availableLocales) { - val userLanguage = state.userLocale.toLanguageTag() - state.availableLocales.map { locale -> - val tag = locale.toLanguageTag() - DialogItem( - selected = tag == userLanguage, - leadingContent = { - Box { - SelectedLeadingContent(tag == userLanguage) - } - }, - headlineContent = { - Text(locale.getDisplayName(locale)) - }, - supportingContent = { - Text(locale.getDisplayName(Locale.ENGLISH)) - }, - dismissOnClick = true, - onClick = { - viewModel.changeLocale(locale) - }, - ) - } - } - DialogPopupContent( - title = stringResource(R.string.user_interface_language), - dialogItems = dialogItems, - waiting = false, - onDismissRequest = onDismissRequest, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index 4a9787a0..a4686b10 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -19,13 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun <T> MultiChoicePreference( title: String, summary: String?, - possibleValues: List<T>, + possibleValues: Set<T>, selectedValues: Set<T>, onValueChange: (List<T>) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, - subtitleDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> }, + valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -60,8 +59,7 @@ fun <T> MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(index, item) }, - supportingContent = { subtitleDisplay.invoke(index, item) }, + headlineContent = { valueDisplay.invoke(item) }, trailingContent = { Switch( checked = selectedValues.contains(item), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt deleted file mode 100644 index 7deeb95c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ /dev/null @@ -1,376 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import android.content.Context -import androidx.annotation.StringRes -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.relocation.BringIntoViewRequester -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.tv.material3.ListItem -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Switch -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.ServerPreferencesDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavDrawerService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.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.WholphinDispatchers -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -data class NavDrawerPin( - val id: String, - val title: String, - val pinned: Boolean, - val item: NavDrawerItem, -) { - companion object { - fun create( - context: Context, - items: Map<NavDrawerItem, Boolean>, - ) { - items.map { (item, pinned) -> - NavDrawerPin(item.id, item.name(context), pinned, item) - } - } - } -} - -private fun <T> List<T>.move( - direction: MoveDirection, - index: Int, -): List<T> = - toMutableList().apply { - if (direction == MoveDirection.DOWN) { - val down = this[index] - val up = this[index + 1] - set(index, up) - set(index + 1, down) - } else { - val up = this[index] - val down = this[index - 1] - set(index - 1, up) - set(index, down) - } - } - -@Composable -fun NavDrawerPreference( - title: String, - summary: String?, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - viewModel: NavDrawerPreferencesViewModel = hiltViewModel(), -) { - val items by viewModel.state.collectAsState() - var showDialog by remember { mutableStateOf(false) } - ClickPreference( - title = title, - summary = summary, - onClick = { showDialog = true }, - interactionSource = interactionSource, - modifier = modifier, - ) - if (showDialog) { - NavDrawerPreferenceDialog( - items = items, - onDismissRequest = { - viewModel.save() - showDialog = false - }, - onClick = { index -> - val newItems = - items.toMutableList().apply { - set(index, items[index].let { it.copy(pinned = !it.pinned) }) - } - viewModel.update(newItems) - }, - onMoveUp = { index -> - viewModel.update(items.move(MoveDirection.UP, index)) - }, - onMoveDown = { index -> - viewModel.update(items.move(MoveDirection.DOWN, index)) - }, - ) - } -} - -@Composable -fun NavDrawerPreferenceDialog( - items: List<NavDrawerPin>, - onDismissRequest: () -> Unit, - onClick: (Int) -> Unit, - onMoveUp: (Int) -> Unit, - onMoveDown: (Int) -> Unit, -) { - val scope = rememberCoroutineScope() - val bringIntoViewRequesters = remember { List(items.size) { BringIntoViewRequester() } } - BasicDialog( - onDismissRequest = onDismissRequest, - elevation = 3.dp, - ) { - Column( - modifier = - Modifier - .padding(16.dp), - ) { - Text( - text = stringResource(R.string.nav_drawer_pins), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp), - ) - val listState = rememberLazyListState() - - fun ensureVisible(index: Int) { - val idx = index.coerceIn(items.indices) - scope.launch { - bringIntoViewRequesters[idx].bringIntoView() - } - } - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - itemsIndexed(items, key = { _, item -> item.id }) { index, item -> - NavDrawerPreferenceListItem( - title = item.title, - pinned = item.pinned, - moveUpAllowed = index > 0, - moveDownAllowed = index < items.lastIndex, - onClick = { onClick.invoke(index) }, - onMoveUp = { - onMoveUp.invoke(index) - ensureVisible(index - 1) - }, - onMoveDown = { - onMoveDown.invoke(index) - ensureVisible(index + 1) - }, - modifier = - Modifier - .animateItem() - .bringIntoViewRequester(bringIntoViewRequesters[index]), - ) - } - } - } - } -} - -@Composable -fun NavDrawerPreferenceListItem( - title: String, - pinned: Boolean, - moveUpAllowed: Boolean, - moveDownAllowed: Boolean, - onClick: () -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .heightIn(min = 40.dp), - ) { - ListItem( - selected = false, - headlineContent = { - Text( - text = title, - ) - }, - trailingContent = { - Switch( - checked = pinned, - onCheckedChange = { - onClick.invoke() - }, - ) - }, - onClick = onClick, - modifier = Modifier.weight(1f), - ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.wrapContentWidth(), - ) { - MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) - MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) - } - } - } -} - -@Composable -fun MoveButton( - @StringRes icon: Int, - enabled: Boolean, - onClick: () -> Unit, -) = Button( - onClick = onClick, - enabled = enabled, - modifier = Modifier.size(32.dp), -) { - Text( - text = stringResource(icon), - fontSize = 16.sp, - fontFamily = FontAwesome, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) -} - -@PreviewTvSpec -@Composable -fun NavDrawerPreferenceListItemPreview() { - WholphinTheme { - NavDrawerPreferenceListItem( - title = "Movies", - pinned = true, - moveUpAllowed = true, - moveDownAllowed = true, - onClick = {}, - onMoveUp = {}, - onMoveDown = { }, - modifier = Modifier.width(360.dp), - ) - } -} - -@HiltViewModel -class NavDrawerPreferencesViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - val navigationManager: NavigationManager, - val backdropService: BackdropService, - private val serverRepository: ServerRepository, - private val navDrawerService: NavDrawerService, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - ) : ViewModel() { - val state = MutableStateFlow<List<NavDrawerPin>>(listOf()) - - init { - viewModelScope.launchDefault { - val state = navDrawerService.state.value - val user = serverRepository.currentUser - val seerr = seerrServerRepository.active.firstOrNull() - if (user == null || seerr == null) { - return@launchDefault - } - val navDrawerPins = - withContext(WholphinDispatchers.IO) { - serverPreferencesDao - .getNavDrawerPinnedItems(user) - .associateBy { it.itemId } - } - val allItems = state.let { it.items + it.moreItems } - val pins = - allItems - .sortedBy { - navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE - }.mapNotNull { - if (!seerr && it is NavDrawerItem.Discover) { - null - } else { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - NavDrawerPin( - it.id, - it.name(context), - pinned == NavPinType.PINNED, - it, - ) - } - } - this@NavDrawerPreferencesViewModel.state.value = pins - } - } - - fun update(items: List<NavDrawerPin>) { - state.update { items } - } - - fun save() { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser?.let { user -> - serverRepository.currentUserDto?.let { userDto -> - if (user.id == userDto.id) { - val toSave = - state.value.mapIndexed { index, item -> - NavDrawerPinnedItem( - user.rowId, - item.id, - if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, - index, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - 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 81f4833b..71dd09fc 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 @@ -1,28 +1,22 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences import kotlinx.serialization.Serializable /** * A group of preferences */ -data class PreferenceGroup<T>( +data class PreferenceGroup( @param:StringRes val title: Int, - val preferences: List<AppPreference<T, out Any?>>, - val conditionalPreferences: List<ConditionalPreferences<T>> = listOf(), + val preferences: List<AppPreference<out Any?>>, + val conditionalPreferences: List<ConditionalPreferences> = listOf(), ) -data class ConditionalPreferences<T>( - val condition: (T) -> Boolean, - val preferences: List<AppPreference<T, out Any?>>, +data class ConditionalPreferences( + val condition: (AppPreferences) -> Boolean, + val preferences: List<AppPreference<out Any?>>, ) /** @@ -40,58 +34,11 @@ sealed interface PreferenceValidation { enum class PreferenceScreenOption { BASIC, ADVANCED, - EXO_PLAYER, - MPV, - SCREENSAVER, - SKIP_SEGMENTS, - EXPERIMENTAL, - HDR_FORMATS, + USER_INTERFACE, + SUBTITLES, ; companion object { fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC } } - -@Composable -fun rememberSeerrDialogBody( - user: SeerrUser, - server: SeerrServer, - pluginProvided: Boolean, - ex: Exception? = null, -): String { - val username = - when (user.authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> user.username ?: stringResource(R.string.unknown) - - SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key) - } - val method = - when (user.authMethod) { - SeerrAuthMethod.LOCAL -> R.string.seerr_local_user - SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user - SeerrAuthMethod.API_KEY -> 0 - }.let { - if (it != 0) " (${stringResource(it)})" else "" - } - - val body = - """ - ${stringResource(R.string.url)}: ${server.url} - ${stringResource(R.string.username)}: $username$method - """.trimIndent() + - if (pluginProvided) { - "\n\n${stringResource(R.string.managed_by_server)}" - } else { - "" - } + - if (ex != null) { - "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" - } else { - "" - } - - return remember { body } -} 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 0e388213..1547f2e2 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 @@ -6,26 +6,22 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Image 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.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width 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.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -36,61 +32,35 @@ 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.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader -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.HdrFormatPreferences -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.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.services.Release -import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.ui.components.ConfirmDialog -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.ScrollableDialog import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings -import com.github.damontecres.wholphin.ui.serverSupportedMessage -import com.github.damontecres.wholphin.ui.setup.ReleaseNotes +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog -import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode -import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber -import java.util.Locale @Composable fun PreferencesContent( @@ -99,85 +69,46 @@ fun PreferencesContent( modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), updateVM: UpdateViewModel = hiltViewModel(), - seerrVm: SwitchSeerrViewModel = hiltViewModel(), - onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current - val resources = LocalResources.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() - var showPinFlow by remember { mutableStateOf(false) } - var showVersionDialog by remember { mutableStateOf(false) } - val players by viewModel.externalPlayers.collectAsState() - val serverSupported by viewModel.serverSupported.collectAsState() - val serverVersionMessage = - remember(resources, serverSupported) { - resources.serverSupportedMessage(serverSupported.second, serverSupported.first) - } - var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } - val seerrConnection by viewModel.seerrConnection.collectAsState() - var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } - var showQuickConnectDialog by remember { mutableStateOf(false) } - var showLocaleChoiceDialog by remember { mutableStateOf(false) } + val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { preferences = it } } - var updateCache by remember { mutableStateOf(false) } - LaunchedEffect(updateCache) { - val imageUsedMemory = context.imageLoader.memoryCache?.size ?: 0L - val imageMaxMemory = context.imageLoader.memoryCache?.maxSize ?: 0L - val imageDisk = context.imageLoader.diskCache?.size ?: 0L - cacheUsage = CacheUsage(imageUsedMemory, imageMaxMemory, imageDisk) - updateCache = false - } - val updateState by updateVM.state.collectAsState() - val release = updateState.release - LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) { + val release by updateVM.release.observeAsState(null) + LaunchedEffect(Unit) { if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) { - updateVM.init() + updateVM.init(preferences.updateUrl) } } - val movementSounds = false + val movementSounds = true val installedVersion = updateVM.currentVersion - val updateAvailable = - remember(updateState.release) { - updateState.release?.version?.isGreaterThan(installedVersion) ?: false - } + val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false val prefList = when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> basicPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences - PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences - PreferenceScreenOption.MPV -> MpvPreferences - PreferenceScreenOption.SCREENSAVER -> screensaverPreferences - PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences - PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences - PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences + PreferenceScreenOption.USER_INTERFACE -> uiPreferences + PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences } val screenTitle = when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> R.string.settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings - PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options - 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 - PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats + PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface + PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style } var visible by remember { mutableStateOf(false) } @@ -192,7 +123,7 @@ fun PreferencesContent( try { System.loadLibrary("mpv") System.loadLibrary("player") - } catch (ex: UnsatisfiedLinkError) { + } catch (ex: Exception) { Timber.w(ex, "Could not load libmpv") showToast(context, "MPV is not supported on this device") viewModel.preferenceDataStore.updateData { @@ -202,12 +133,6 @@ fun PreferencesContent( } } - val showUpdate = - UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable - AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -217,584 +142,248 @@ fun PreferencesContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - Column( + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), ) { - Text( - text = stringResource(screenTitle), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - LazyColumn( - state = state, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(16.dp), - modifier = Modifier.fillMaxSize(), + stickyHeader { + Text( + text = stringResource(screenTitle), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + if (UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable ) { - serverVersionMessage?.let { message -> - item { - ListItem( - selected = false, - enabled = true, - onClick = {}, - headlineContent = { - PreferenceTitle(message, color = MaterialTheme.colorScheme.error) - }, - modifier = Modifier, - ) + item { + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() + } } + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = release?.version?.toString(), + modifier = + Modifier + .focusRequester(updateFocusRequester) + .playSoundOnFocus(movementSounds), + ) } - if (showUpdate) { + } + 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<Any> item { val interactionSource = remember { MutableInteractionSource() } val focused = interactionSource.collectIsFocusedAsState().value - val updateFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (focusedIndex.first == 0 && focusedIndex.second == 0) { - // Only re-focus if the user hasn't moved - updateFocusRequester.tryRequestFocus() - } - } - ClickPreference( - title = stringResource(R.string.install_update), - onClick = { + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = release?.version?.toString(), - interactionSource = interactionSource, - modifier = - Modifier - .focusRequester(updateFocusRequester) - .focusRequester(firstFocusRequester) - .playSoundOnFocus(movementSounds) - .lazyListWrapScrolling( - state = state, - focused = focused, - isFirst = true, - isLast = false, - firstFocusRequester = firstFocusRequester, - lastFocusRequester = lastFocusRequester, - ), - ) - } - } - - 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), - ) - if (group.title == R.string.about) { - WolfskinAbout() - } - } - val groupPreferences = - group.preferences + - group.conditionalPreferences - .filter { it.condition.invoke(preferences) } - .map { it.preferences } - .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, - ) - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - if (movementSounds) playOnClickSound(context) - onFocus.invoke(groupIndex, prefIndex) - } } - when (pref) { - AppPreference.InstalledVersion -> { - ClickPreference( - title = stringResource(R.string.installed_version), - onClick = { - showVersionDialog = true - }, - onLongClick = { + } + when (pref) { + AppPreference.InstalledVersion -> { + var clickCount by remember { mutableIntStateOf(0) } + ClickPreference( + title = stringResource(R.string.installed_version), + onClick = { + if (movementSounds) playOnClickSound(context) + if (clickCount++ >= 2) { + clickCount = 0 viewModel.navigationManager.navigateTo(Destination.Debug) - }, - summary = installedVersion.toString(), - interactionSource = interactionSource, - modifier = focusModifier, - ) - } - - AppPreference.Update -> { - ClickPreference( - title = - if (release != null && updateAvailable) { - stringResource(R.string.install_update) - } else if (!preferences.autoCheckForUpdates && release == null) { - stringResource(R.string.check_for_updates) - } else { - stringResource(R.string.no_update_available) - }, - onClick = { - if (movementSounds) playOnClickSound(context) - if (release != null && updateAvailable) { - release?.let { - viewModel.navigationManager.navigateTo( - Destination.UpdateApp, - ) - } - } else { - updateVM.init() - } - }, - onLongClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = - if (updateAvailable) { - release?.version?.toString() - } else { - null - }, - interactionSource = interactionSource, - modifier = focusModifier, - ) - } - - AppPreference.ClearImageCache -> { - val summary = - remember(cacheUsage) { - cacheUsage.let { - val diskMB = - it.imageDiskUsed / AppPreference.MEGA_BIT - val memoryUsedMB = - it.imageMemoryUsed / AppPreference.MEGA_BIT - val memoryMaxMB = - it.imageMemoryMax / AppPreference.MEGA_BIT - "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" - } } - ClickPreference( - title = stringResource(pref.title), - onClick = { - SingletonImageLoader.get(context).let { - it.memoryCache?.clear() - it.diskCache?.clear() - updateCache = true - } - }, - modifier = focusModifier, - summary = summary, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.UserPinnedNavDrawerItems -> { - NavDrawerPreference( - title = stringResource(pref.title), - summary = pref.summary(context, null), - modifier = focusModifier, - interactionSource = interactionSource, - ) - } - - AppPreference.SendAppLogs -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.sendAppLogs() - }, - modifier = focusModifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.resetSubtitleSettings() - }, - modifier = focusModifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.ProtectProfilePreference -> { - val summary = - when { - currentUser?.requireLogin == true -> R.string.require_login - currentUser?.hasPin == true -> R.string.require_pin_code - else -> R.string.none - } - ChoicePreference( - title = stringResource(pref.title), - summary = stringResource(summary), - interactionSource = interactionSource, - possibleValues = - listOf( - stringResource(R.string.none), - stringResource(R.string.require_pin_code), - stringResource(R.string.require_login), + }, + summary = installedVersion.toString(), + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), ), - selectedIndex = - when { - currentUser?.requireLogin == true -> ProfileProtection.LOGIN - currentUser?.hasPin == true -> ProfileProtection.PIN - else -> ProfileProtection.NONE - }.ordinal, - onValueChange = { - currentUser?.let { user -> - when (ProfileProtection.entries[it]) { - ProfileProtection.NONE -> { - viewModel.removeLoginAndPin(user) - } + ) + } - ProfileProtection.PIN -> { - showPinFlow = true - } - - ProfileProtection.LOGIN -> { - viewModel.setRequireLogin(user) - } - } + AppPreference.Update -> { + ClickPreference( + title = + if (release != null && updateAvailable) { + stringResource(R.string.install_update) + } else if (!preferences.autoCheckForUpdates && release == null) { + stringResource(R.string.check_for_updates) + } else { + stringResource(R.string.no_update_available) + }, + onClick = { + if (movementSounds) playOnClickSound(context) + if (release != null && updateAvailable) { + release?.let { + viewModel.navigationManager.navigateTo(Destination.UpdateApp) } - }, - modifier = focusModifier, - ) - } - - AppPreference.SeerrIntegration -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - seerrDialogMode = - when (val conn = seerrConnection.connection) { - is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error( - conn.server, - conn.user, - conn.ex, - ) - } - - SeerrConnectionStatus.NotConfigured -> { - SeerrDialogMode.Add - } - - is SeerrConnectionStatus.Success -> { - SeerrDialogMode.Remove( - conn.current, - ) - } - } - }, - modifier = focusModifier, - summary = - when (seerrConnection.connection) { - is SeerrConnectionStatus.Error -> { - stringResource(R.string.voice_error_server) - } - - SeerrConnectionStatus.NotConfigured -> { - stringResource(R.string.add_server) - } - - is SeerrConnectionStatus.Success -> { - stringResource(R.string.enabled) - } - }, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.QuickConnect -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - if (currentUser != null) { - viewModel.resetQuickConnectStatus() - showQuickConnectDialog = true - } - }, - modifier = focusModifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - ScreensaverPreference.Start -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.screensaverService.start() - }, - modifier = focusModifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.ExternalPlayerApp -> { - val value = pref.getter.invoke(preferences).toString() - val selectedIndex = - remember(value, players) { - players.indexOfFirstOrNull { it.identifier == value } - } ?: 0 - ChoicePreference( - title = stringResource(pref.title), - summary = players[selectedIndex].name, - interactionSource = interactionSource, - possibleValues = players, - selectedIndex = selectedIndex, - onValueChange = { index -> - scope.launch(ExceptionHandler()) { - val newValue = - players.getOrNull(index)?.identifier ?: "" - preferences = - viewModel.preferenceDataStore.updateData { prefs -> - pref.setter.invoke(prefs, newValue) - } - } - }, - valueDisplay = { index, item -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (item.icon != null) { - Image( - bitmap = item.icon, - contentDescription = null, - modifier = Modifier.width(40.dp), - ) - } - Text(item.name) - } - }, - modifier = focusModifier, - ) - } - - AppPreference.UserInterfaceLanguage -> { - val locale = - remember(currentUser?.uiLanguage) { - currentUser?.uiLanguage?.let { Locale.forLanguageTag(it) } - ?: Locale.getDefault() + } else { + updateVM.init(preferences.updateUrl) } - ClickPreference( - title = stringResource(pref.title), - onClick = { - showLocaleChoiceDialog = true + }, + onLongClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = + if (updateAvailable) { + release?.version?.toString() + } else { + null }, - modifier = focusModifier, - summary = locale.getDisplayName(locale), - onLongClick = null, - interactionSource = interactionSource, - ) - } + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } - 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) - } - } - }, - ) + AppPreference.ClearImageCache -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + } + }, + modifier = Modifier, + summary = null, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.UserPinnedNavDrawerItems -> { + val selectedItems = + navDrawerPins.keys.mapNotNull { + if (navDrawerPins[it] ?: false) it else null } + MultiChoicePreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + possibleValues = navDrawerPins.keys, + selectedValues = selectedItems.toSet(), + onValueChange = { newSelectedItems -> + viewModel.updatePins(newSelectedItems) + }, + ) { + Text(it.name(context)) } + } - 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() - } + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - preferences = - viewModel.preferenceDataStore.updateData { prefs -> - pref.setter(prefs, newValue) - } - } + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.resetSubtitleSettings() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } } } - }, - interactionSource = interactionSource, - modifier = focusModifier, - ) - } + } + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) } } } } } } - if (showPinFlow && currentUser != null) { - currentUser?.let { user -> - SetPinFlow( - currentPin = user.pin, - onAddPin = { - viewModel.setPin(user, it) - showPinFlow = false - }, - onRemovePin = { - viewModel.setPin(user, null) - showPinFlow = false - }, - onDismissRequest = { showPinFlow = false }, - ) - } - } - } - - SeerrDialog( - seerrDialogMode = seerrDialogMode, - seerrConnection = seerrConnection, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - seerrVm = seerrVm, - ) - - if (showQuickConnectDialog) { - val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) - val successMessage = stringResource(R.string.quick_connect_success) - - LaunchedEffect(quickConnectStatus) { - when (val status = quickConnectStatus) { - LoadingState.Success -> { - Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() - showQuickConnectDialog = false - } - - is LoadingState.Error -> { - val errorMessage = status.message ?: "Authorization failed" - Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() - } - - else -> {} - } - } - - QuickConnectDialog( - onSubmit = { code -> - viewModel.authorizeQuickConnect(code) - }, - onDismissRequest = { - viewModel.resetQuickConnectStatus() - showQuickConnectDialog = false - }, - ) - } - if (showVersionDialog) { - LaunchedEffect(Unit) { - viewModel.fetchReleaseNotes() - } - val release by viewModel.releaseNotes.collectAsState() - ScrollableDialog( - onDismissRequest = { showVersionDialog = false }, - ) { - item { - when (val r = release) { - is DataLoadingState.Error -> { - ErrorMessage(message = "Error", exception = r.exception) - } - - DataLoadingState.Pending, - DataLoadingState.Loading, - -> { - LoadingPage() - } - - is DataLoadingState.Success<Release> -> { - ReleaseNotes(r.data) - } - } - } - } - } - if (showLocaleChoiceDialog) { - LocaleChoiceDialog( - onDismissRequest = { showLocaleChoiceDialog = false }, - ) } } @@ -810,13 +399,8 @@ fun PreferencesPage( when (preferenceScreenOption) { PreferenceScreenOption.BASIC, PreferenceScreenOption.ADVANCED, - PreferenceScreenOption.EXO_PLAYER, - PreferenceScreenOption.MPV, - PreferenceScreenOption.SCREENSAVER, - PreferenceScreenOption.SKIP_SEGMENTS, - PreferenceScreenOption.EXPERIMENTAL, - PreferenceScreenOption.HDR_FORMATS, - -> { + PreferenceScreenOption.USER_INTERFACE, + -> PreferencesContent( initialPreferences, preferenceScreenOption, @@ -825,13 +409,11 @@ fun PreferencesPage( .fillMaxHeight() .align(Alignment.TopEnd), ) - } + + PreferenceScreenOption.SUBTITLES -> + SubtitleStylePage( + initialPreferences, + ) } } } - -data class CacheUsage( - val imageMemoryUsed: Long, - val imageMemoryMax: Long, - val imageDiskUsed: Long, -) 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 2e0ce392..33cd8b56 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 @@ -1,45 +1,31 @@ package com.github.damontecres.wholphin.ui.preferences -import android.content.ComponentName import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.core.graphics.drawable.toBitmap -import androidx.core.net.toUri import androidx.datastore.core.DataStore +import androidx.lifecycle.MutableLiveData 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.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.isPinned +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences -import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.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.ui.setup.ServerVersionSupported -import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient -import timber.log.Timber +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo import javax.inject.Inject @HiltViewModel @@ -50,71 +36,62 @@ class PreferencesViewModel private val api: ApiClient, val preferenceDataStore: DataStore<AppPreferences>, val navigationManager: NavigationManager, - val backdropService: BackdropService, - val screensaverService: ScreensaverService, + private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val seerrServerRepository: SeerrServerRepository, - private val updateChecker: UpdateChecker, - 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 - - private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) - val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus - - val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending) - - val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList()) - - val serverSupported = - serverRepository.currentServerFlow - .map { - val serverVersion = it?.serverVersion - serverVersion to - when { - serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED - serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED - serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE - else -> ServerVersionSupported.SUPPORTED - } - }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null to ServerVersionSupported.SUPPORTED, - ) + private val navDrawerItemRepository: NavDrawerItemRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val deviceInfo: DeviceInfo, + private val clientInfo: ClientInfo, + ) : ViewModel(), + RememberTabManager by rememberTabManager { + private lateinit var allNavDrawerItems: List<NavDrawerItem> + val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf()) init { viewModelScope.launchIO { - val fakeIntent = - Intent(Intent.ACTION_VIEW).apply { - setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") - } - val externalPlayers = getExternalPlayers(context) - val systemDefault = - ExternalPlayerApp( - name = context.getString(R.string.system_default), - icon = null, - identifier = "", - ) - this@PreferencesViewModel.externalPlayers.update { listOf(systemDefault) + externalPlayers } + serverRepository.currentUser.value?.let { user -> + allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) + val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } + this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } + } + } + + fun updatePins(newSelectedItems: List<NavDrawerItem>) { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser.value?.let { user -> + val disabledItems = + mutableListOf<NavDrawerItem>().apply { + addAll(allNavDrawerItems) + removeAll(newSelectedItems) + } + val enabledItems = newSelectedItems.toSet() + val toSave = + disabledItems.map { + NavDrawerPinnedItem( + user.rowId, + it.id, + NavPinType.UNPINNED, + ) + } + + enabledItems.map { + NavDrawerPinnedItem( + user.rowId, + it.id, + NavPinType.PINNED, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) + val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } + this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } } } fun sendAppLogs() { - viewModelScope.launchIO { serverReportService.sendAppLogs() } + sendAppLogs(context, api, clientInfo, deviceInfo) } fun resetSubtitleSettings() { @@ -123,65 +100,6 @@ class PreferencesViewModel } } - fun setPin( - user: JellyfinUser, - pin: String?, - ) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - serverRepository.updateUserAuth(user, pin, false) - } - } - - fun setRequireLogin(user: JellyfinUser) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - serverRepository.updateUserAuth(user, null, true) - } - } - - fun removeLoginAndPin(user: JellyfinUser) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - serverRepository.updateUserAuth(user, null, false) - } - } - - fun resetQuickConnectStatus() { - _quickConnectStatus.value = LoadingState.Pending - } - - fun authorizeQuickConnect(code: String) { - viewModelScope.launchIO { - _quickConnectStatus.value = LoadingState.Loading - try { - val success = serverRepository.authorizeQuickConnect(code) - _quickConnectStatus.value = - if (success) { - LoadingState.Success - } else { - LoadingState.Error("Authorization failed") - } - } catch (e: Exception) { - _quickConnectStatus.value = LoadingState.Error(e) - } - } - } - - fun fetchReleaseNotes() { - viewModelScope.launchIO { - releaseNotes.update { DataLoadingState.Loading } - try { - val release = updateChecker.getRelease(updateChecker.getInstalledVersion()) - if (release != null) { - releaseNotes.update { DataLoadingState.Success(release) } - } else { - releaseNotes.update { DataLoadingState.Error("Release not found") } - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching release") - releaseNotes.update { DataLoadingState.Error(ex) } - } - } - } - companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) { appPreferences.updateData { @@ -192,37 +110,3 @@ class PreferencesViewModel } } } - -data class ExternalPlayerApp( - val name: String, - val icon: ImageBitmap?, - val identifier: String, -) - -fun getExternalPlayers(context: Context): List<ExternalPlayerApp> { - val fakeIntent = - Intent(Intent.ACTION_VIEW).apply { - setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*") - } - val externalPlayers = - context.packageManager - .queryIntentActivities(fakeIntent, PackageManager.MATCH_ALL) - .filter { it.priority >= 0 } - .map { - val component = - ComponentName( - it.activityInfo.packageName, - it.activityInfo.name, - ) - ExternalPlayerApp( - name = it.loadLabel(context.packageManager).toString(), - icon = - it - .loadIcon(context.packageManager) - .toBitmap() - .asImageBitmap(), - identifier = component.flattenToString(), - ) - } - return externalPlayers -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt deleted file mode 100644 index dfad49b5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.components.TextButton - -@Composable -fun QuickConnectDialog( - onSubmit: (String) -> Unit, - onDismissRequest: () -> Unit, - elevation: Dp = 3.dp, -) { - var code by remember { mutableStateOf("") } - var showError by remember { mutableStateOf(false) } - - val isValidCode: (String) -> Boolean = { value -> - val trimmed = value.trim() - trimmed.length == 6 && trimmed.all { it.isDigit() } - } - - val onSubmitCode = { - if (isValidCode(code)) { - showError = false - onSubmit(code.trim()) - } else { - showError = true - } - } - - Dialog( - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - onDismissRequest = onDismissRequest, - ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .padding(16.dp) - .width(360.dp) - .background( - color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), - shape = RoundedCornerShape(8.dp), - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(16.dp), - ) { - Text( - text = stringResource(R.string.quick_connect_code), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - EditTextBox( - value = code, - onValueChange = { - code = it - showError = false - }, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = - KeyboardActions( - onDone = { - onSubmitCode() - }, - ), - isInputValid = { value -> - !showError || isValidCode(value) - }, - modifier = Modifier.fillMaxWidth(), - ) - - if (showError) { - Text( - text = stringResource(R.string.quick_connect_code_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 8.dp), - ) - } - - TextButton( - stringRes = R.string.submit, - onClick = onSubmitCode, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt deleted file mode 100644 index 2c6bcd55..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SetPinFlow.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.setup.PinEntryCreate - -enum class PinFlowState { - ENTER_PIN_TO_REMOVE, - ENTER_PIN, - CONFIRM_PIN, -} - -enum class ProfileProtection { - NONE, - PIN, - LOGIN, -} - -@Composable -fun SetPinFlow( - currentPin: String?, - onAddPin: (String) -> Unit, - onRemovePin: () -> Unit, - onDismissRequest: () -> Unit, -) { - val context = LocalContext.current - var flowState by - remember { - mutableStateOf(if (currentPin.isNullOrBlank()) PinFlowState.ENTER_PIN else PinFlowState.ENTER_PIN_TO_REMOVE) - } - var pendingPin by remember { mutableStateOf("") } - - AnimatedContent(flowState) { state -> - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - when (state) { - PinFlowState.ENTER_PIN_TO_REMOVE -> { - PinEntryCreate( - title = R.string.enter_pin, - onTextChange = { - if (it == currentPin) onRemovePin.invoke() - }, - onConfirm = null, - modifier = Modifier.padding(16.dp), - ) - } - - PinFlowState.ENTER_PIN -> { - PinEntryCreate( - title = R.string.enter_pin, - onTextChange = {}, - onConfirm = { - if (it.length >= 4) { - pendingPin = it - flowState = PinFlowState.CONFIRM_PIN - } else { - Toast - .makeText( - context, - context.getString(R.string.pin_too_short), - Toast.LENGTH_SHORT, - ).show() - } - }, - modifier = Modifier.padding(16.dp), - ) - } - - PinFlowState.CONFIRM_PIN -> { - PinEntryCreate( - title = R.string.confirm_pin, - onTextChange = {}, - onConfirm = { - if (it == pendingPin) { - onAddPin.invoke(pendingPin) - } else { - Toast - .makeText( - context, - context.getString(R.string.incorrect), - Toast.LENGTH_SHORT, - ).show() - flowState = PinFlowState.ENTER_PIN - } - }, - modifier = Modifier.padding(16.dp), - ) - } - } - } - } -} 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 459ec479..4aefde8c 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,6 +18,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.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -27,7 +28,7 @@ import com.github.damontecres.wholphin.ui.components.SliderBar @Composable fun SliderPreference( - preference: AppSliderPreference<*>, + preference: AppSliderPreference, title: String, summary: String?, value: Long, @@ -35,6 +36,7 @@ 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 @@ -47,22 +49,23 @@ fun SliderPreference( val contentColor = contentColorFor(background) Column( - verticalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier - .defaultMinSize(minHeight = 72.dp) +// .height(80.dp) // not dense + .height(72.dp + heightAdjustment) // dense .fillMaxWidth() .background(background, shape = RoundedCornerShape(8.dp)) - .padding(PaddingValues(horizontal = 16.dp, vertical = 12.dp)), + .padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense, ) { - PreferenceTitle(title, color = contentColor, modifier = Modifier.padding(bottom = 8.dp)) + PreferenceTitle(title, color = contentColor) ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) { additionalSummary?.invoke(this) } Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -74,6 +77,7 @@ fun SliderPreference( max = preference.max, interval = preference.interval, onChange = onChange, + color = MaterialTheme.colorScheme.border, enableWrapAround = false, interactionSource = interactionSource, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt deleted file mode 100644 index 0e40ad24..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/StringInputDialog.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.ConfirmDialog -import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.components.TextButton - -@Composable -fun StringInputDialog( - input: StringInput, - onSave: (String) -> Unit, - onDismissRequest: () -> Unit, - elevation: Dp = 3.dp, -) { - val mutableValue = rememberTextFieldState(input.value ?: "") - var mutableText by remember { mutableStateOf(input.value ?: "") } -// var mutableValue by remember { mutableStateOf(input.value ?: "") } - val onDone = { - if (input.maxLines > 1) { - onSave.invoke(mutableValue.text.toString()) - } else { - onSave.invoke(mutableText) - } - } - var showConfirm by remember { mutableStateOf(false) } - Dialog( - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - onDismissRequest = { - if (input.confirmDiscard && mutableValue.text.toString() != input.value) { - showConfirm = true - } else { - onDismissRequest.invoke() - } - }, - ) { - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .padding(16.dp) - .width(512.dp) - .background( - color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), - shape = RoundedCornerShape(8.dp), - ), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(16.dp), - ) { - Text( - text = input.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier, - ) - if (input.maxLines > 1) { - EditTextBox( - state = mutableValue, - keyboardOptions = input.keyboardOptions, - onKeyboardAction = { - onDone.invoke() - }, - leadingIcon = null, - isInputValid = { true }, - maxLines = input.maxLines, - modifier = Modifier.fillMaxWidth(), - ) - } else { - EditTextBox( - value = mutableText, - onValueChange = { mutableText = it }, - keyboardOptions = input.keyboardOptions, - keyboardActions = - KeyboardActions( - onDone = { - onDone.invoke() - }, - ), - leadingIcon = null, - isInputValid = { true }, - modifier = Modifier.fillMaxWidth(), - ) - } - - Row( - horizontalArrangement = Arrangement.SpaceAround, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton( - stringRes = R.string.cancel, - onClick = onDismissRequest, - modifier = Modifier, - ) - TextButton( - stringRes = R.string.save, - onClick = onDone, - modifier = Modifier, - ) - } - } - } - } - - if (showConfirm) { - ConfirmDialog( - title = stringResource(R.string.discard_change), - body = null, - onCancel = { - showConfirm = false - }, - onConfirm = { - showConfirm = false - onDismissRequest.invoke() - }, - elevation = elevation * 2, - ) - } -} 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 daf6fa07..a7b25b82 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 @@ -71,21 +71,17 @@ fun SwitchColors(): SwitchColors { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, AppThemeColors.BLUE, - -> { + -> SwitchDefaults.colors() - } AppThemeColors.GREEN, AppThemeColors.ORANGE, AppThemeColors.BOLD_BLUE, AppThemeColors.OLED_BLACK, - AppThemeColors.RED, - AppThemeColors.BROWN, - -> { + -> SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, uncheckedThumbColor = MaterialTheme.colorScheme.onPrimary, ) - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt deleted file mode 100644 index a0814180..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalUriHandler -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.components.TextButton - -/** Fork identity and a focusable link to the upstream project. */ -@Composable -fun WolfskinAbout() { - val uriHandler = LocalUriHandler.current - val upstreamUrl = stringResource(R.string.wolfskin_upstream_url) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(R.string.app_name_long), - style = MaterialTheme.typography.headlineMedium, - ) - TextButton(onClick = { uriHandler.openUri(upstreamUrl) }) { - Text(stringResource(R.string.wolfskin_upstream_credit)) - } - } -} 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 deleted file mode 100644 index 32da9613..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.github.damontecres.wholphin.ui.preferences.subtitle - -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.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 -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.SubtitlePreferences -import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling -import com.github.damontecres.wholphin.preferences.resetSubtitles -import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.preferences.ClickPreference -import com.github.damontecres.wholphin.ui.preferences.ComposablePreference -import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup -import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.launch - -@Composable -fun SubtitlePreferencesContent( - title: String, - preferences: SubtitlePreferences, - prefList: List<PreferenceGroup<SubtitlePreferences>>, - onPreferenceChange: suspend (SubtitlePreferences) -> Unit, - modifier: Modifier = Modifier, - viewModel: PreferencesViewModel = hiltViewModel(), - onFocus: (Int, Int) -> Unit = { _, _ -> }, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val focusRequester = remember { FocusRequester() } - val firstFocusRequester = remember { FocusRequester() } - val lastFocusRequester = remember { FocusRequester() } - var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } - val state = rememberLazyListState() - - var visible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - // Forces the animated to trigger - visible = true - } - - AnimatedVisibility( - visible = visible, - enter = fadeIn() + slideInHorizontally { it / 2 }, - exit = fadeOut() + slideOutHorizontally { it / 2 }, - modifier = modifier, - ) { - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - Column( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), - ) { - 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 { - 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) - } - } - 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, - ) - - 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, - ) - } - - 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 = 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 a737fe2a..88521068 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 @@ -1,51 +1,41 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle -import android.content.res.Configuration import android.graphics.Typeface -import android.os.Build -import android.view.Display -import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Density -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 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.preferences.updateSubtitlePreferences 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 object SubtitleSettings { val FontSize = - AppSliderPreference<SubtitlePreferences>( + AppSliderPreference( title = R.string.font_size, defaultValue = 24, min = 8, max = 70, interval = 2, getter = { - it.fontSize.toLong() + it.interfacePreferences.subtitlesPreferences.fontSize + .toLong() }, setter = { prefs, value -> - prefs.update { fontSize = value.toInt() } + prefs.updateSubtitlePreferences { fontSize = value.toInt() } }, summarizer = { value -> value?.toString() }, ) @@ -65,12 +55,12 @@ object SubtitleSettings { ) val FontColor = - AppChoicePreference<SubtitlePreferences, Color>( + AppChoicePreference<Color>( title = R.string.font_color, defaultValue = Color.White, - getter = { Color(it.fontColor) }, + getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, setter = { prefs, value -> - prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) } + prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -81,49 +71,49 @@ object SubtitleSettings { ) val FontBold = - AppSwitchPreference<SubtitlePreferences>( + AppSwitchPreference( title = R.string.bold_font, defaultValue = false, - getter = { it.fontBold }, + getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, setter = { prefs, value -> - prefs.update { fontBold = value } + prefs.updateSubtitlePreferences { fontBold = value } }, ) val FontItalic = - AppSwitchPreference<SubtitlePreferences>( + AppSwitchPreference( title = R.string.italic_font, defaultValue = false, - getter = { it.fontItalic }, + getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, setter = { prefs, value -> - prefs.update { fontItalic = value } + prefs.updateSubtitlePreferences { fontItalic = value } }, ) val FontOpacity = - AppSliderPreference<SubtitlePreferences>( + AppSliderPreference( title = R.string.font_opacity, defaultValue = 100, min = 10, max = 100, interval = 10, getter = { - it.fontOpacity + it.interfacePreferences.subtitlesPreferences.fontOpacity .toLong() }, setter = { prefs, value -> - prefs.update { fontOpacity = value.toInt() } + prefs.updateSubtitlePreferences { fontOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val EdgeStylePref = - AppChoicePreference<SubtitlePreferences, EdgeStyle>( + AppChoicePreference<EdgeStyle>( title = R.string.edge_style, defaultValue = EdgeStyle.EDGE_SOLID, - getter = { it.edgeStyle }, + getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle }, setter = { prefs, value -> - prefs.update { edgeStyle = value } + prefs.updateSubtitlePreferences { edgeStyle = value } }, displayValues = R.array.subtitle_edge, indexToValue = { EdgeStyle.forNumber(it) }, @@ -131,12 +121,12 @@ object SubtitleSettings { ) val EdgeColor = - AppChoicePreference<SubtitlePreferences, Color>( + AppChoicePreference<Color>( title = R.string.edge_color, defaultValue = Color.Black, - getter = { Color(it.edgeColor) }, + getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, setter = { prefs, value -> - prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) } + prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -146,30 +136,13 @@ object SubtitleSettings { }, ) - val EdgeThickness = - AppSliderPreference<SubtitlePreferences>( - title = R.string.edge_size, - defaultValue = 4, - min = 1, - max = 32, - interval = 1, - getter = { - it.edgeThickness - .toLong() - }, - setter = { prefs, value -> - prefs.update { edgeThickness = value.toInt() } - }, - summarizer = { value -> value?.let { "${it / 2.0}" } }, - ) - val BackgroundColor = - AppChoicePreference<SubtitlePreferences, Color>( + AppChoicePreference<Color>( title = R.string.background_color, defaultValue = Color.Transparent, - getter = { Color(it.backgroundColor) }, + getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, setter = { prefs, value -> - prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) } + prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -180,92 +153,43 @@ object SubtitleSettings { ) val BackgroundOpacity = - AppSliderPreference<SubtitlePreferences>( + AppSliderPreference( title = R.string.background_opacity, defaultValue = 50, min = 10, max = 100, interval = 10, getter = { - it.backgroundOpacity + it.interfacePreferences.subtitlesPreferences.backgroundOpacity .toLong() }, setter = { prefs, value -> - prefs.update { backgroundOpacity = value.toInt() } + prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val BackgroundStylePref = - AppChoicePreference<SubtitlePreferences, BackgroundStyle>( + AppChoicePreference<BackgroundStyle>( title = R.string.background_style, defaultValue = BackgroundStyle.BG_NONE, - getter = { it.backgroundStyle }, + getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle }, setter = { prefs, value -> - prefs.update { backgroundStyle = value } + prefs.updateSubtitlePreferences { backgroundStyle = value } }, displayValues = R.array.background_style, indexToValue = { BackgroundStyle.forNumber(it) }, valueToIndex = { it.number }, ) - val Margin = - AppSliderPreference<SubtitlePreferences>( - title = R.string.subtitle_margin, - defaultValue = 8, - min = 0, - max = 100, - interval = 1, - getter = { - it.margin - .toLong() - }, - setter = { prefs, value -> - prefs.update { margin = value.toInt() } - }, - summarizer = { value -> value?.let { "$it%" } }, - ) - - val ImageOpacity = - AppSliderPreference<SubtitlePreferences>( - title = R.string.image_subtitle_opacity, - defaultValue = 100, - min = 10, - max = 100, - interval = 5, - getter = { - it.imageSubtitleOpacity.toLong() - }, - setter = { prefs, value -> - prefs.update { imageSubtitleOpacity = value.toInt() } - }, - summarizer = { value -> value?.let { "$it%" } }, - ) - val Reset = - AppClickablePreference<SubtitlePreferences>( + AppClickablePreference( title = R.string.reset, getter = { }, 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, - destination = Destination.SubtitleSettings(true), - ) - val preferences = listOf( PreferenceGroup( @@ -285,7 +209,6 @@ object SubtitleSettings { listOf( EdgeStylePref, EdgeColor, - EdgeThickness, ), ), PreferenceGroup( @@ -300,52 +223,24 @@ object SubtitleSettings { PreferenceGroup( title = R.string.more, preferences = - listOf( - Margin, - ImageOpacity, - Reset, - ), + listOf(Reset), ), ) - val hdrPreferenceGroup = - listOf( - PreferenceGroup( - title = R.string.hdr, - preferences = - listOf( - SeparateHdr, - ), - conditionalPreferences = - listOf( - ConditionalPreferences( - condition = { it.useSeparateHdr }, - preferences = listOf(HdrSettings), - ), - ), - ), - ) - - fun shouldShowHdr(display: Display): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && display.isHdr - - private fun combine( - color: Int, - opacity: Int, - ) = ((opacity / 100.0 * 255).toInt().shl(24)).or(color.and(0x00FFFFFF)) - @OptIn(UnstableApi::class) fun SubtitlePreferences.toSubtitleStyle(): CaptionStyleCompat { - val bg = combine(backgroundColor, backgroundOpacity) + val fo = (fontOpacity / 100.0 * 255).toInt().shl(24) + val bg = (backgroundOpacity / 100.0 * 255).toInt().shl(24).or(backgroundColor) return CaptionStyleCompat( - combine(fontColor, fontOpacity), - if (backgroundStyle == BackgroundStyle.BG_WRAP) bg else 0, + fo.or(fontColor), + if (backgroundStyle == BackgroundStyle.BG_WRAP)bg else 0, if (backgroundStyle == BackgroundStyle.BG_BOXED) bg else 0, when (edgeStyle) { EdgeStyle.EDGE_NONE, EdgeStyle.UNRECOGNIZED -> CaptionStyleCompat.EDGE_TYPE_NONE EdgeStyle.EDGE_SOLID -> CaptionStyleCompat.EDGE_TYPE_OUTLINE EdgeStyle.EDGE_SHADOW -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW }, - combine(edgeColor, fontOpacity), + fo.or(edgeColor), when { fontBold && fontItalic -> Typeface.defaultFromStyle(Typeface.BOLD_ITALIC) fontBold -> Typeface.defaultFromStyle(Typeface.BOLD) @@ -355,23 +250,11 @@ 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( - configuration: Configuration, - density: Density, - ) { + fun SubtitlePreferences.applyToMpv(density: Density) { val fo = (fontOpacity / 100.0 * 255).toInt().shl(24) - val fc = Color(combine(fontColor, fontOpacity)) - val bg = Color(combine(backgroundColor, backgroundOpacity)) - val edge = Color(combine(edgeColor, fontOpacity)) + val fc = Color(fo.or(fontColor)) + val bg = Color((backgroundOpacity / 100.0 * 255).toInt().shl(24).or(backgroundColor)) + val edge = Color(fo.or(edgeColor)) // TODO weird, but seems to get the size to be very close to matching sizes between renderers val fontSizePx = with(density) { fontSize.sp.toPx() * .8 }.toInt() @@ -379,11 +262,6 @@ object SubtitleSettings { MPVLib.setPropertyColor("sub-color", fc) MPVLib.setPropertyColor("sub-outline-color", edge) - val heightInPx = with(density) { configuration.screenHeightDp.dp.toPx() } - val margin = (heightInPx * (margin.toFloat() / 100f) * .8).toInt() - MPVLib.setPropertyInt("sub-margin-y", margin) - Timber.d("MPV subtitles: fontSizePx=%s, margin=$margin", fontSizePx, margin) - when (edgeStyle) { EdgeStyle.EDGE_NONE, EdgeStyle.UNRECOGNIZED, @@ -402,8 +280,6 @@ object SubtitleSettings { MPVLib.setPropertyDouble("sub-outline-size", 0.0) } } - val outlineSizePx = calculateEdgeSize(density) * .8 - MPVLib.setPropertyDouble("sub-outline-size", outlineSizePx) // if (fontBold) { // MPVLib.setPropertyString("sub-font", "Roboto Bold") @@ -421,29 +297,8 @@ object SubtitleSettings { -> "outline-and-shadow" BackgroundStyle.BG_WRAP -> "opaque-box" - BackgroundStyle.BG_BOXED -> "background-box" } MPVLib.setPropertyString("sub-border-style", borderStyle) } } - -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 a96ab8fe..6a33710c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -1,7 +1,5 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle -import android.content.pm.ActivityInfo -import android.os.Build import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -14,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,13 +19,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -37,54 +29,27 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.SubtitlePreferences -import com.github.damontecres.wholphin.preferences.resetSubtitles -import com.github.damontecres.wholphin.preferences.updateInterfacePreferences -import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.preferences.PreferencesContent import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle -import com.github.damontecres.wholphin.util.Media3SubtitleOverride import timber.log.Timber @OptIn(UnstableApi::class) @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, - isPageForHdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val density = LocalDensity.current var preferences by remember { mutableStateOf(initialPreferences) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { preferences = it } } - val display = LocalView.current.display - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - DisposableEffect(context) { - if (isPageForHdrSettings) { - Timber.v("Switching color mode to HDR") - context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR - } - onDispose { - context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT - } - } - } - val prefs = - if (isPageForHdrSettings) { - preferences.interfacePreferences.hdrSubtitlesPreferences - } else { - preferences.interfacePreferences.subtitlesPreferences - } - var focusedOnMargin by remember { mutableStateOf(false) } - var focusedOnImageOpacity by remember { mutableStateOf(false) } - + Timber.v("SubtitleStylePage") + val prefs = preferences.interfacePreferences.subtitlesPreferences Row( modifier = modifier, ) { @@ -103,137 +68,45 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) - if (!focusedOnMargin && !focusedOnImageOpacity) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .padding(16.dp) - .fillMaxSize(), - ) { - val examples = - mapOf( - "Subtitles will look like this" to 48.dp, - "This is another example" to 24.dp, - "Longer multi line subtitles will\nlook like this" to 0.dp, - ) - examples.forEach { (text, padding) -> - AndroidView( - factory = { context -> - SubtitleView(context) - }, - update = { - it.setStyle(prefs.toSubtitleStyle()) - it.setFixedTextSize(Dimension.SP, prefs.fontSize.toFloat()) - it.setCues( - listOf( - Cue.Builder().setText(text).build(), - ), - ) - Media3SubtitleOverride(prefs.calculateEdgeSize(density)).apply(it) - }, - modifier = - Modifier - .weight(1f) - .fillMaxWidth() - .padding(bottom = padding), - ) - } + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + val examples = + mapOf( + "Subtitles will look like this" to 48.dp, + "This is another example" to 24.dp, + "Longer multi line subtitles will\nlook like this" to 0.dp, + ) + examples.forEach { (text, padding) -> + AndroidView( + factory = { context -> + SubtitleView(context) + }, + update = { + it.setStyle(prefs.toSubtitleStyle()) + it.setFixedTextSize(Dimension.SP, prefs.fontSize.toFloat()) + it.setCues( + listOf( + Cue.Builder().setText(text).build(), + ), + ) + }, + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = padding), + ) } - } else if (focusedOnMargin) { - // Margin - AndroidView( - factory = { context -> - SubtitleView(context) - }, - update = { - it.setStyle(prefs.toSubtitleStyle()) - it.setFixedTextSize(Dimension.SP, prefs.fontSize.toFloat()) - it.setCues( - listOf( - Cue.Builder().setText("Subtitles margin below here").build(), - ), - ) - it.setBottomPaddingFraction(prefs.margin.toFloat() / 100f) - }, - modifier = - Modifier - .fillMaxSize(), - ) - } else if (focusedOnImageOpacity) { - AndroidView( - factory = { context -> - SubtitleView(context) - }, - update = { - it.setStyle( - SubtitlePreferences - .newBuilder() - .apply { - resetSubtitles() - }.build() - .toSubtitleStyle(), - ) - it.setCues( - listOf( - Cue - .Builder() - .setText("ExoPlayer only:\nImage based subtitles can be dimmed.") - .build(), - ), - ) - }, - modifier = - Modifier - .fillMaxSize() - .alpha(prefs.imageSubtitleOpacity / 100f), - ) } } - val display = LocalView.current.display - val prefList = - 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 { - SubtitleSettings.preferences - } - } - SubtitlePreferencesContent( - title = - if (isPageForHdrSettings) { - stringResource(R.string.hdr_subtitle_style) - } else { - stringResource(R.string.subtitle_style) - }, - preferences = - if (isPageForHdrSettings) { - preferences.interfacePreferences.hdrSubtitlesPreferences - } else { - preferences.interfacePreferences.subtitlesPreferences - }, - prefList = prefList, - onPreferenceChange = { newSubtitlePrefs -> - viewModel.preferenceDataStore.updateData { - it.updateInterfacePreferences { - if (isPageForHdrSettings) { - hdrSubtitlesPreferences = newSubtitlePrefs - } else { - subtitlesPreferences = newSubtitlePrefs - } - } - } - }, - onFocus = { groupIndex, prefIndex -> - val focusedPref = - SubtitleSettings.preferences - .getOrNull(groupIndex) - ?.preferences - ?.getOrNull(prefIndex) - focusedOnMargin = focusedPref == SubtitleSettings.Margin - focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity - }, + PreferencesContent( + initialPreferences = preferences, + preferenceScreenOption = PreferenceScreenOption.SUBTITLES, modifier = Modifier .fillMaxHeight() 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 deleted file mode 100644 index c68239b0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/FilterableLanguagePreference.kt +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index dc8da479..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/PreferredLanguage.kt +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 8b4dd64f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserPreferencesViewModel.kt +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 1308e08e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/user/UserProfilePreferencesPage.kt +++ /dev/null @@ -1,321 +0,0 @@ -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/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt deleted file mode 100644 index 17beee6c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt +++ /dev/null @@ -1,304 +0,0 @@ -package com.github.damontecres.wholphin.ui.search - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.HorizontalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.ItemRowTitle -import com.github.damontecres.wholphin.ui.cards.SeasonCard -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.SearchEditTextBox -import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import kotlinx.coroutines.delay -import org.jellyfin.sdk.model.api.BaseItemKind - -@Composable -fun SearchForContent( - searchType: BaseItemKind, - onClick: (BaseItem) -> Unit, - modifier: Modifier = Modifier, - viewModel: SearchForViewModel = - hiltViewModel<SearchForViewModel, SearchForViewModel.Factory>( - key = searchType.serialName, - creationCallback = { - it.create(searchType) - }, - ), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val state by viewModel.state.collectAsState() - - var query by rememberSaveable { mutableStateOf("") } - val searchFocusRequester = remember { FocusRequester() } - val focusRequester = remember { FocusRequester() } - - var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } - - LifecycleResumeEffect(Unit) { - onPauseOrDispose { - viewModel.voiceInputManager.stopListening() - } - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - viewModel.search(searchType, searchQuery) - } - - LaunchedEffect(query) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750L) - viewModel.search(searchType, query) - } - } - } - - val titleRes = - remember { - when (searchType) { - BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.PLAYLIST -> R.string.playlists - else -> null - } - } - val title = titleRes?.let { stringResource(it) } ?: "" - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.search_for, title), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - var isSearchActive by remember { mutableStateOf(false) } - var isTextFieldFocused by remember { mutableStateOf(false) } - val textFieldFocusRequester = remember { FocusRequester() } - - BackHandler(isTextFieldFocused) { - when { - isSearchActive -> { - isSearchActive = false - keyboardController?.hide() - } - - else -> { - focusManager.moveFocus(FocusDirection.Next) - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .focusGroup() - .focusRestorer(textFieldFocusRequester) - .focusRequester(searchFocusRequester), - ) { - VoiceSearchButton( - onSpeechResult = { spokenText -> - query = spokenText - triggerImmediateSearch(spokenText) - }, - voiceInputManager = viewModel.voiceInputManager, - ) - - SearchEditTextBox( - value = query, - onValueChange = { - isSearchActive = true - query = it - }, - onSearchClick = { triggerImmediateSearch(query) }, - readOnly = !isSearchActive, - modifier = - Modifier - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - isTextFieldFocused = state.isFocused - if (!state.isFocused) isSearchActive = false - }.onPreviewKeyEvent { event -> - val isActivationKey = - event.key in listOf(Key.DirectionCenter, Key.Enter) - if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { - isSearchActive = true - keyboardController?.show() - true - } else { - false - } - }, - ) - } - } - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - SearchForResultsRow( - title = stringResource(R.string.results), - results = state.results, - onClick = onClick, - modifier = Modifier.focusRequester(focusRequester), - ) - } - item { - HorizontalDivider() - } - item { - SearchForResultsRow( - title = stringResource(R.string.suggestions), - results = state.recent, - onClick = onClick, - modifier = Modifier, - ) - } - } - } -} - -@Composable -fun SearchForResultsRow( - title: String, - results: SearchResult, - onClick: (BaseItem) -> Unit, - modifier: Modifier = Modifier, -) { - when (val st = results) { - is SearchResult.Error -> { - ErrorMessage("Error", st.ex, modifier) - } - - SearchResult.NoQuery -> { - ItemRowTitle( - title = stringResource(R.string.no_results), - modifier = modifier, - ) - } - - SearchResult.Searching -> { - ItemRowTitle( - title = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.SuccessSeerr -> { - Text( - text = "Not supported", - color = MaterialTheme.colorScheme.error, - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (st.items.isEmpty()) { - ItemRowTitle( - title = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { - ItemRow( - title = title, - items = st.items, - onClickItem = { _, item -> onClick.invoke(item) }, - onLongClickItem = { _, _ -> }, - modifier = modifier, - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - modifier = mod, - ) - }, - ) - } - } - } -} - -@Composable -fun SearchForDialog( - onDismissRequest: () -> Unit, - searchType: BaseItemKind, - onClick: (BaseItem) -> Unit, -) { - BasicDialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - elevation = 3.dp, - ) { - SearchForContent( - searchType = searchType, - onClick = onClick, - modifier = - Modifier - .padding(8.dp) - .fillMaxWidth(.8f) - .fillMaxHeight(.66f), - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt deleted file mode 100644 index e34697ed..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.github.damontecres.wholphin.ui.search - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.toBaseItems -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -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.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber - -@HiltViewModel(assistedFactory = SearchForViewModel.Factory::class) -class SearchForViewModel - @AssistedInject - constructor( - private val api: ApiClient, - private val serverRepository: ServerRepository, - val navigationManager: NavigationManager, - val voiceInputManager: VoiceInputManager, - @Assisted val searchType: BaseItemKind, - ) : ViewModel() { - @AssistedFactory - interface Factory { - fun create(searchType: BaseItemKind): SearchForViewModel - } - - val state = MutableStateFlow(SearchForState()) - - init { - state.value = SearchForState() - viewModelScope.launchIO { - try { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - includeItemTypes = listOf(searchType), - recursive = true, - fields = SlimItemFields, - sortBy = listOf(ItemSortBy.DATE_LAST_CONTENT_ADDED), - sortOrder = listOf(SortOrder.DESCENDING), - limit = 25, - ) - val recent = api.itemsApi.getItems(request).toBaseItems(api, false) - state.update { - it.copy(recent = SearchResult.Success(recent)) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error fetching recent %s", searchType) - state.update { - it.copy(recent = SearchResult.Error(ex)) - } - } - } - } - - fun search( - searchType: BaseItemKind, - query: String, - ) { - viewModelScope.launchIO { - if (state.value.query != query) { - if (query.isBlank()) { - state.update { it.copy(query = query, results = SearchResult.NoQuery) } - return@launchIO - } - state.update { it.copy(query = query, results = SearchResult.NoQuery) } - try { - val request = - GetItemsRequest( - userId = serverRepository.currentUser?.id, - searchTerm = query, - includeItemTypes = listOf(searchType), - recursive = true, - fields = SlimItemFields, - ) - val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() - state.update { - it.copy( - query = query, - results = SearchResult.Success(pager), - ) - } - } catch (ex: Exception) { - Timber.e(ex) - state.update { it.copy(query = query, results = SearchResult.Error(ex)) } - } - } - } - } - } - -data class SearchForState( - val query: String = "", - val recent: SearchResult = SearchResult.Searching, - val results: SearchResult = SearchResult.NoQuery, -) 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 deleted file mode 100644 index f20222d9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt +++ /dev/null @@ -1,774 +0,0 @@ -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 deleted file mode 100644 index 9b82f24c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index f8a6eb2e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 044178b2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ /dev/null @@ -1,627 +0,0 @@ -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 deleted file mode 100644 index cdbd69e1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt +++ /dev/null @@ -1,121 +0,0 @@ -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/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index a3bd52e4..4b5de09d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -21,14 +21,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -38,8 +41,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -49,65 +54,52 @@ import com.github.damontecres.wholphin.services.DownloadCallback import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec 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.components.TextButton -import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.Version import com.mikepenz.markdown.m3.Markdown -import com.mikepenz.markdown.m3.markdownTypography import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class UpdateViewModel @Inject constructor( - private val userPreferencesService: UserPreferencesService, val updater: UpdateChecker, val navigationManager: NavigationManager, ) : ViewModel(), DownloadCallback { - private val _state = MutableStateFlow(InstallUpdateState()) - val state: StateFlow<InstallUpdateState> = _state + val loading = MutableLiveData<LoadingState>(LoadingState.Pending) + val release = MutableLiveData<Release?>(null) - private val _bytesDownloaded = MutableStateFlow(0L) - val bytesDownloaded: StateFlow<Long> = _bytesDownloaded + val downloading = MutableLiveData<Boolean>(false) + val contentLength = MutableLiveData<Long>(-1) + val bytesDownloaded = MutableLiveData<Long>(-1) val currentVersion = updater.getInstalledVersion() - fun init() { - _state.update { it.copy(loading = LoadingState.Loading) } - viewModelScope.launchIO { - val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl - try { - val release = updater.getLatestRelease(updateUrl) - _state.update { - it.copy( - loading = LoadingState.Success, - release = release, - contentLength = -1L, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching release from %s", updateUrl) - _state.update { it.copy(loading = LoadingState.Error(ex)) } + fun init(updateUrl: String) { + loading.value = LoadingState.Loading + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { + val release = updater.getLatestRelease(updateUrl) + withContext(Dispatchers.Main) { + contentLength.value = -1 + bytesDownloaded.value = -1 + this@UpdateViewModel.release.value = release + loading.value = LoadingState.Success } } } @@ -116,59 +108,61 @@ class UpdateViewModel fun installRelease(release: Release) { downloadJob = - viewModelScope.launchIO { - try { - _bytesDownloaded.value = 0L - _state.update { it.copy(downloading = true) } - updater.installRelease(release, this@UpdateViewModel) - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Error downloading") - _state.update { it.copy(loading = LoadingState.Error(ex)) } - } finally { - _state.update { it.copy(downloading = false) } - } + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Failed to install update", + ), + ) { + downloading.setValueOnMain(true) + updater.installRelease(release, this@UpdateViewModel) + downloading.setValueOnMain(false) } } fun cancelDownload() { - viewModelScope.launchIO { + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error", + ), + ) { downloadJob?.cancel() - _state.update { - it.copy( - downloading = false, - contentLength = -1L, - ) + withContext(Dispatchers.Main) { + downloading.value = false + contentLength.value = -1 + bytesDownloaded.value = -1 } } } override fun contentLength(contentLength: Long) { - _state.update { it.copy(contentLength = contentLength) } + this@UpdateViewModel.contentLength.value = contentLength } override fun bytesDownloaded(bytes: Long) { - _bytesDownloaded.value = bytes + this@UpdateViewModel.bytesDownloaded.value = bytes } } -data class InstallUpdateState( - val loading: LoadingState = LoadingState.Pending, - val downloading: Boolean = false, - val release: Release? = null, - val contentLength: Long = -1L, -) - @Composable fun InstallUpdatePage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: UpdateViewModel = hiltViewModel(), ) { - OneTimeLaunchedEffect { viewModel.init() } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val release by viewModel.release.observeAsState(null) - val state by viewModel.state.collectAsState() + val isDownloading by viewModel.downloading.observeAsState(false) + val contentLength by viewModel.contentLength.observeAsState(-1L) + val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1) + + LaunchedEffect(Unit) { + viewModel.init(preferences.appPreferences.updateUrl) + } var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) } val launcher = rememberLauncherForActivityResult( @@ -180,44 +174,40 @@ fun InstallUpdatePage( // TODO } } - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } - + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state, modifier) LoadingState.Loading, LoadingState.Pending, - -> { - LoadingPage(modifier) - } + -> LoadingPage(modifier) LoadingState.Success -> { - val release = state.release - if (release != null) { + release?.let { InstallUpdatePageContent( currentVersion = viewModel.currentVersion, - release = release, + release = it, onInstallRelease = { if (!permissions) { launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { - viewModel.installRelease(release) + viewModel.installRelease(it) } }, onCancel = { viewModel.navigationManager.goBack() }, - modifier = modifier.dimAndBlur(state.downloading), - ) - } else { - Text( - text = "No release found! Check the update URL.", + modifier = + modifier + .ifElse( + isDownloading, + Modifier + .blur(8.dp) + .alpha(.33f), + ), ) } - if (state.downloading) { - val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L) + if (isDownloading) { DownloadDialog( - contentLength = state.contentLength, + contentLength = contentLength, bytesDownloaded = bytesDownloaded, onDismissRequest = { viewModel.cancelDownload() @@ -285,7 +275,15 @@ fun InstallUpdatePageContent( }, ) { item { - ReleaseNotes(release) + Markdown( + (release.notes.joinToString("\n\n") + (release.body ?: "")) + .replace( + Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"), + "#$1", + ) + // Remove the last line for full changelog since its just a link + .replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""), + ) } } Column( @@ -310,37 +308,24 @@ fun InstallUpdatePageContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) - TextButton( - stringRes = R.string.download_and_update, + Button( onClick = onInstallRelease, - ) - TextButton( - stringRes = R.string.cancel, + ) { + Text( + text = stringResource(R.string.download_and_update), + ) + } + Button( onClick = onCancel, - ) + ) { + Text( + text = stringResource(R.string.cancel), + ) + } } } } -@Composable -fun ReleaseNotes( - release: Release, - modifier: Modifier = Modifier, -) { - Markdown( - content = release.content, - typography = - markdownTypography( - h1 = MaterialTheme.typography.headlineLarge, - h2 = MaterialTheme.typography.headlineMedium, - h3 = MaterialTheme.typography.headlineSmall, - text = MaterialTheme.typography.bodySmall, - code = MaterialTheme.typography.bodySmall, - ), - modifier = modifier, - ) -} - @Composable fun DownloadDialog( contentLength: Long, @@ -416,15 +401,13 @@ private fun InstallUpdatePageContentPreview() { downloadUrl = "https://url", publishedAt = null, body = - "## Header 2\n" + - "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + "Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " + "Iaculis massa nisl malesuada lacinia integer nunc posuere. " + "Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " + "torquent per conubia nostra inceptos himenaeos.\n\n" + - "### Header 3\n" + "Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " + "ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " + "tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " + 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 deleted file mode 100644 index ef97620c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/PinEntry.kt +++ /dev/null @@ -1,255 +0,0 @@ -package com.github.damontecres.wholphin.ui.setup - -import androidx.annotation.StringRes -import androidx.compose.foundation.background -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.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.draw.clip -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -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.TextButton -import com.github.damontecres.wholphin.ui.playback.isEnterKey -import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -@Composable -fun PinEntry( - onTextChange: (String) -> Unit, - onClickServerAuth: () -> Unit, - modifier: Modifier = Modifier, -) { - var input by remember { mutableStateOf("") } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.enter_pin), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - PinArrowRow(Modifier.align(Alignment.CenterHorizontally)) - PinEntryDots(input.length, Modifier.align(Alignment.CenterHorizontally)) - - TextButton( - onClick = onClickServerAuth, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .onKeyEvent { - if (it.type == KeyEventType.KeyUp) { - var str = input - str += - when (it.key) { - Key.DirectionUp -> "U" - Key.DirectionRight -> "R" - Key.DirectionDown -> "D" - Key.DirectionLeft -> "L" - Key.Zero, Key.NumPad0 -> "0" - Key.One, Key.NumPad1 -> "1" - Key.Two, Key.NumPad2 -> "2" - Key.Three, Key.NumPad3 -> "3" - Key.Four, Key.NumPad4 -> "4" - Key.Five, Key.NumPad5 -> "5" - Key.Six, Key.NumPad6 -> "6" - Key.Seven, Key.NumPad7 -> "7" - Key.Eight, Key.NumPad8 -> "8" - Key.Nine, Key.NumPad9 -> "9" - else -> return@onKeyEvent false - } - onTextChange.invoke(str) - input = str - return@onKeyEvent true - } else { - return@onKeyEvent false - } - }, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.use_server_credentials), - ) - Text( - text = stringResource(R.string.will_remove_pin), - fontSize = 10.sp, - ) - } - } - } -} - -@Composable -fun PinEntryCreate( - @StringRes title: Int, - onTextChange: (String) -> Unit, - onConfirm: ((String) -> Unit)?, - modifier: Modifier = Modifier, -) { - var input by remember { mutableStateOf("") } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .onKeyEvent { - if (isEnterKey(it)) { - onConfirm?.invoke(input) - return@onKeyEvent true - } - if (it.type == KeyEventType.KeyUp) { - var str = input - str += - when (it.key) { - Key.DirectionUp -> "U" - Key.DirectionRight -> "R" - Key.DirectionDown -> "D" - Key.DirectionLeft -> "L" - Key.Zero, Key.NumPad0 -> "0" - Key.One, Key.NumPad1 -> "1" - Key.Two, Key.NumPad2 -> "2" - Key.Three, Key.NumPad3 -> "3" - Key.Four, Key.NumPad4 -> "4" - Key.Five, Key.NumPad5 -> "5" - Key.Six, Key.NumPad6 -> "6" - Key.Seven, Key.NumPad7 -> "7" - Key.Eight, Key.NumPad8 -> "8" - Key.Nine, Key.NumPad9 -> "9" - else -> return@onKeyEvent false - } - onTextChange.invoke(str) - input = str - return@onKeyEvent true - } else { - return@onKeyEvent false - } - }.focusable(), - ) { - Text( - text = stringResource(title), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - PinArrowRow(Modifier.align(Alignment.CenterHorizontally)) - PinEntryDots(input.length, Modifier.align(Alignment.CenterHorizontally)) - if (onConfirm != null) { - Text( - text = stringResource(R.string.press_enter_to_confirm), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } -} - -@Composable -fun PinArrowRow(modifier: Modifier = Modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - val arrows = - listOf(R.string.fa_arrow_left_long, R.string.fa_arrow_up_long, R.string.fa_arrow_right_long, R.string.fa_arrow_down_long) - arrows.forEach { - Text( - text = stringResource(it), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - fontFamily = FontAwesome, - ) - } - Text( - text = stringResource(R.string.pin_digits_hint), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - } -} - -@Composable -fun PinEntryDots( - count: Int, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = - modifier - .defaultMinSize(minWidth = 180.dp, minHeight = 40.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f), - shape = CircleShape, - ).padding(vertical = 16.dp), - ) { - repeat(count) { - Box( - modifier = - Modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) - .size(8.dp), - ) - } - } -} - -@Composable -fun PinEntryDialog( - onDismissRequest: () -> Unit, - onTextChange: (String) -> Unit, - onClickServerAuth: () -> Unit, -) { - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - PinEntry( - onTextChange = onTextChange, - onClickServerAuth = onClickServerAuth, - modifier = Modifier.padding(16.dp), - ) - } -} - -@PreviewTvSpec -@Composable -private fun PinEntryPreview() { - WholphinTheme { - PinEntry( - onTextChange = {}, - onClickServerAuth = {}, - modifier = Modifier, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index bd14c38c..827cf44b 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 @@ -1,47 +1,38 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.tv.material3.Border -import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Icon +import androidx.tv.material3.IconButtonDefaults +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.CircularProgress -import org.jellyfin.sdk.model.api.PublicSystemInfo +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import java.util.UUID sealed interface ServerConnectionStatus { - data class Success( - val systemInfo: PublicSystemInfo, - ) : ServerConnectionStatus + object Success : ServerConnectionStatus object Pending : ServerConnectionStatus @@ -51,270 +42,107 @@ sealed interface ServerConnectionStatus { } /** - * Generate a consistent color for a UUID + * Display a list of servers plus option to add a new one */ @Composable -fun rememberIdColor( - id: UUID?, - alpha: Float = 1f, - nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, -): Color = - remember(id, alpha) { - if (id == null) { - return@remember nullColor - } - // Generate a color based on the server ID hash, fallback to URL hash - val hash = id.hashCode() - val hue = (hash % 360).toFloat() - val saturation = 0.6f + ((hash / 360) % 40).toFloat() / 100f // 0.6-1.0 - val brightness = 0.4f + ((hash / 14400) % 30).toFloat() / 100f // 0.4-0.7 (darker colors) - - // Convert HSV to RGB - val c = brightness * saturation - val x = c * (1 - kotlin.math.abs((hue / 60f) % 2f - 1)) - val m = brightness - c - - val (r, g, b) = - when { - hue < 60 -> Triple(c, x, 0f) - hue < 120 -> Triple(x, c, 0f) - hue < 180 -> Triple(0f, c, x) - hue < 240 -> Triple(0f, x, c) - hue < 300 -> Triple(x, 0f, c) - else -> Triple(c, 0f, x) - } - - Color( - red = (r + m).coerceIn(0f, 1f), - green = (g + m).coerceIn(0f, 1f), - blue = (b + m).coerceIn(0f, 1f), - alpha = alpha, - ) - } - -/** - * Server icon card component - displays a circular card with server name/letter - */ -@Composable -fun ServerIconCard( - server: JellyfinServer, - serverVersionSupported: ServerVersionSupported, - connectionStatus: ServerConnectionStatus, - isCurrentServer: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, +fun ServerList( + servers: List<JellyfinServer>, + connectionStatus: Map<UUID, ServerConnectionStatus>, + onSwitchServer: (JellyfinServer) -> Unit, + onTestServer: (JellyfinServer) -> Unit, + onAddServer: () -> Unit, + onRemoveServer: (JellyfinServer) -> Unit, + allowAdd: Boolean, + allowDelete: Boolean, modifier: Modifier = Modifier, - allowDelete: Boolean = false, ) { - val interactionSource = remember { MutableInteractionSource() } + var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) } - // Generate unique color for this server - val serverColor = rememberIdColor(server.id) - - // Card dimensions - circular card - val cardSize = Cards.serverUserCircle - - val displayText = - remember(server) { - (server.name ?: server.url.replace(Regex("^https?://"), "")) - .firstOrNull() - ?.uppercase() - ?: "?" - } - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // Circular card with colored background - Surface( - onClick = onClick, - onLongClick = if (allowDelete) onLongClick else null, - interactionSource = interactionSource, - modifier = Modifier.size(cardSize), - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = - if (isCurrentServer) { - serverColor.copy(alpha = 0.7f) - } else { - serverColor.copy(alpha = 0.5f) - }, - focusedContainerColor = - if (isCurrentServer) { - serverColor.copy(alpha = 0.9f) - } else { - serverColor.copy(alpha = 0.7f) - }, - ), - border = - ClickableSurfaceDefaults.border( - focusedBorder = - Border( - border = - BorderStroke( - width = 3.dp, - color = MaterialTheme.colorScheme.onSurface, - ), - shape = CircleShape, - ), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - // Show connection status indicator or server name/letter - when { - connectionStatus is ServerConnectionStatus.Error || serverVersionSupported != ServerVersionSupported.SUPPORTED -> { - // Show warning icon with server letter below - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + LazyColumn(modifier = modifier) { + items(servers) { server -> + val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) }, + supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) }, + leadingContent = { + when (status) { + ServerConnectionStatus.Success -> {} + ServerConnectionStatus.Pending -> { + CircularProgress( + Modifier.size(IconButtonDefaults.MediumIconSize), + ) + } + is ServerConnectionStatus.Error -> { Icon( imageVector = Icons.Default.Warning, - contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, - tint = - if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) { - AppColors.GoldenYellow - } else { - MaterialTheme.colorScheme.errorContainer - }, - modifier = Modifier.size(cardSize * 0.3f), - ) - Text( - text = displayText, - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, + contentDescription = status.message, + tint = MaterialTheme.colorScheme.errorContainer, ) } } - - 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, - ) + }, + onClick = { + when (status) { + ServerConnectionStatus.Success -> onSwitchServer.invoke(server) + ServerConnectionStatus.Pending -> {} + is ServerConnectionStatus.Error -> onTestServer.invoke(server) } - - connectionStatus == ServerConnectionStatus.Pending -> { - CircularProgress( - modifier = Modifier.size(cardSize * 0.4f), - ) + }, + onLongClick = { + if (allowDelete) { + showDeleteDialog = server } - } - } + }, + modifier = Modifier, + ) } - - // Server name below the card - Text( - text = server.name?.ifBlank { null } ?: server.url, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .width(cardSize) - .padding(horizontal = 4.dp), - ) - } -} - -/** - * Add Server card component - displays a + icon in a circle - */ -@Composable -fun AddServerCard( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - - // Use a neutral gray color for the add server card - val addServerColor = MaterialTheme.colorScheme.surfaceVariant - - // Card dimensions - circular card (same as server cards) - val cardSize = Cards.height2x3 * 0.75f // ~120dp - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // Circular card with colored background - Surface( - onClick = onClick, - interactionSource = interactionSource, - modifier = - Modifier - .size(cardSize) - .testTag("add_server"), - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = addServerColor.copy(alpha = 0.4f), - focusedContainerColor = addServerColor.copy(alpha = 0.6f), - ), - border = - ClickableSurfaceDefaults.border( - focusedBorder = - Border( - border = - BorderStroke( - width = 3.dp, - color = MaterialTheme.colorScheme.onSurface, - ), - shape = CircleShape, - ), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_server), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon + if (allowAdd) { + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = stringResource(R.string.add_server)) }, + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + tint = Color.Green.copy(alpha = .8f), + contentDescription = null, + ) + }, + onClick = { onAddServer.invoke() }, + modifier = Modifier, ) } } - - // "Add Server" text below the card - Text( - text = stringResource(R.string.add_server), - style = - MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Bold, + } + showDeleteDialog?.let { server -> + DialogPopup( + showDialog = allowDelete, + title = server.name ?: server.url, + dialogItems = + listOf( + DialogItem( + stringResource(R.string.switch_servers), + R.string.fa_arrow_left_arrow_right, + ) { + onSwitchServer.invoke(server) + }, + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + onRemoveServer.invoke(server) + }, ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .width(cardSize) - .padding(horizontal = 4.dp), + onDismissRequest = { showDeleteDialog = null }, + dismissOnClick = true, + waitToLoad = true, + properties = DialogProperties(), + elevation = 5.dp, ) } } 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 dd972f9b..4cb05d43 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,37 +1,20 @@ 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.background 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.Spacer +import androidx.compose.foundation.layout.Row 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.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -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.Delete import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -39,474 +22,224 @@ 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.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 -import androidx.tv.material3.ListItem +import androidx.tv.material3.Button 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.JellyfinServer import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.TextButton -import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.ServerVersion -import timber.log.Timber @Composable fun SwitchServerContent( modifier: Modifier = Modifier, viewModel: SwitchServerViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() + val servers by viewModel.servers.observeAsState(listOf()) + val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) + + val discoveredServers by viewModel.discoveredServers.observeAsState(listOf()) + + var showAddServer by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { viewModel.init() + viewModel.discoverServers() } - when (val st = state.loading) { - is LoadingState.Error -> ErrorMessage(st, modifier) - - LoadingState.Loading, - LoadingState.Pending, - -> LoadingPage(modifier) - - LoadingState.Success -> SwitchServerContentInternal(state, viewModel, modifier) - } -} - -@Composable -private fun SwitchServerContentInternal( - state: SwitchServerState, - viewModel: SwitchServerViewModel, - modifier: Modifier = Modifier, -) { - val resources = LocalResources.current - val focusRequesters = - remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } } - 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), + modifier = modifier, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), + Row( modifier = Modifier - .fillMaxWidth() - // Center the content like the Select User screen .align(Alignment.Center) - .padding(16.dp), + .padding(32.dp), ) { - // Match SwitchUser header height (title + subtitle) to align icons vertically across screens Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(16.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ), ) { Text( text = stringResource(R.string.select_server), style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) - // Invisible subtitle placeholder to mirror the server name line on the Select User screen - Text( - text = "Server placeholder", - style = MaterialTheme.typography.titleLarge, - color = Color.Transparent, - ) - } - - // Horizontal scrollable list of server icons - centered - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - val focusRequester = remember { FocusRequester() } - val firstServerFocus = remember { FocusRequester() } - LaunchedEffect(state.loading) { - val state = state - when (state.loading) { - is LoadingState.Error, - LoadingState.Loading, - LoadingState.Pending, - -> Unit - - LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus() - } - } - LazyRow( - horizontalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), + ServerList( + servers = servers, + connectionStatus = serverStatus, + onSwitchServer = { + viewModel.addServer(it.url) + }, + onTestServer = { + viewModel.testServer(it) + }, + onAddServer = { + showAddServer = true + }, + onRemoveServer = { + viewModel.removeServer(it) + }, + allowAdd = true, + allowDelete = true, modifier = Modifier - .wrapContentWidth() - .focusRestorer(firstServerFocus) - .focusRequester(focusRequester), - ) { - itemsIndexed(state.servers) { index, server -> - ServerIconCard( - server = server.server, - connectionStatus = server.status, - serverVersionSupported = server.versionSupported, - isCurrentServer = false, // TODO: Determine current server if needed - onClick = { - when (server.status) { - is ServerConnectionStatus.Success -> { - viewModel.switchServer(server.server) - } - - ServerConnectionStatus.Pending -> { - // Do nothing while pending - } - - is ServerConnectionStatus.Error -> { - viewModel.testServer(server.server) - } - } - }, - onLongClick = { - showDeleteDialog = server.server - }, - allowDelete = true, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) focusedIndex = index - }.focusRequester(focusRequesters[index]), - ) - } - // Add Server card - always rightmost - item { - AddServerCard( - onClick = { showAddServer = true }, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) focusedIndex = -1 - }.focusRequester(focusRequesters.last()), - ) - } - } + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(16.dp), + ), + ) } - // Non-focusable spacer to mirror the space occupied by the "Switch Servers" button - Spacer( + // Discover + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() - .height(56.dp), - // approximate TV button height - ) - } - - 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 -> - SwitchErrorMessage(message ?: "") - } - - // Delete server dialog - showDeleteDialog?.let { server -> - DialogPopup( - showDialog = true, - title = server.name ?: server.url, - dialogItems = - listOf( - DialogItem( - stringResource(R.string.switch_servers), - R.string.fa_arrow_left_arrow_right, - ) { - viewModel.switchServer(server) - showDeleteDialog = null - }, - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - viewModel.removeServer(server) - showDeleteDialog = null - }, - ), - onDismissRequest = { showDeleteDialog = null }, - dismissOnClick = true, - waitToLoad = true, - properties = DialogProperties(), - elevation = 5.dp, - ) - } - - if (showAddServer) { - AddServerDialog( - onDismissRequest = { showAddServer = false }, - viewModel = viewModel, - ) - } - } -} - -@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 - } - } - - 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 buttonFocusRequester = remember { FocusRequester() } - - // Default focus to first discovered server if available - LaunchedEffect(showEnterAddress) { - if (!showEnterAddress) { - buttonFocusRequester.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 + .weight(1f) + .padding(16.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ), + ) { Text( text = stringResource(R.string.discovered_servers), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) - - if (state.discoveredServers.isEmpty()) { + if (discoveredServers.isEmpty()) { Text( text = stringResource(R.string.searching), - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } else { - LazyColumn( + ServerList( + servers = discoveredServers, + connectionStatus = + discoveredServers + .map { it.id } + .associateWith { ServerConnectionStatus.Success }, + onSwitchServer = { + viewModel.addServer(it.url) + }, + onTestServer = { + viewModel.testServer(it) + }, + onAddServer = {}, + onRemoveServer = {}, + allowAdd = false, + allowDelete = false, modifier = Modifier .fillMaxWidth() - .heightIn(max = 300.dp), - ) { - items( - state.discoveredServers.size, - key = { state.discoveredServers[it].url }, - ) { index -> - val server = state.discoveredServers[index] - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() } + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(16.dp), + ), + ) + } + } + } - 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), - ) - } - } - } - - 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() } - - LaunchedEffect(Unit) { - textBoxFocusRequester.tryRequestFocus() - } - LaunchedEffect(url) { + if (showAddServer) { + LaunchedEffect(Unit) { + viewModel.clearAddServerState() + } + val state by viewModel.addServerState.observeAsState(LoadingState.Pending) + var url by remember { mutableStateOf("") } + val submit = { + viewModel.addServer(url) + } + BasicDialog( + onDismissRequest = { + showAddServer = false viewModel.clearAddServerState() - } - - 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() }, - ), + }, + properties = DialogProperties(usePlatformDefaultWidth = false), + elevation = 10.dp, + ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), 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, + .padding(16.dp) + .fillMaxWidth(.4f), ) { - if (addServerState == LoadingState.Loading) { - CircularProgress(Modifier.size(32.dp)) - } else { - Text(text = stringResource(R.string.submit)) + Text( + text = stringResource(R.string.enter_server_url), + ) + EditTextBox( + value = url, + onValueChange = { + url = it + viewModel.clearAddServerState() + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + keyboardActions = + KeyboardActions( + onDone = { submit.invoke() }, + ), + modifier = + Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + when (val st = state) { + is LoadingState.Error -> { + Text( + text = + st.message ?: st.exception?.localizedMessage + ?: "An error occurred", + color = MaterialTheme.colorScheme.error, + ) + } + + else -> {} + } + Button( + onClick = { submit.invoke() }, + enabled = url.isNotNullOrBlank() && state == LoadingState.Pending, + modifier = Modifier, + ) { + if (state == LoadingState.Loading) { + CircularProgress(Modifier.size(32.dp)) + } else { + Text(text = stringResource(R.string.submit)) + } } } } } } } - -fun serverErrorMessage( - server: ServerState?, - resources: Resources, -): String? = - if (server == null || server.status == ServerConnectionStatus.Pending) { - null - } else if (server.status is ServerConnectionStatus.Success) { - resources.serverSupportedMessage( - server.versionSupported, - server.status.systemInfo.version - ?.let { ServerVersion.fromString(it) }, - ) - } else if (server.status is ServerConnectionStatus.Error) { - server.status.message - } else { - resources.serverSupportedMessage( - server.versionSupported, - null, - ) - } - -@Composable -fun BoxScope.SwitchErrorMessage(message: String?) { - message?.let { - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) - .align(Alignment.BottomCenter), - ) - } -} 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 00beccb3..be93e772 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,39 +1,35 @@ 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.MutableLiveData 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 -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext 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.quickConnectApi 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 +import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -45,89 +41,53 @@ class SwitchServerViewModel val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, - val navigationManager: SetupNavigationManager, + val navigationManager: NavigationManager, ) : ViewModel() { - private val _state = MutableStateFlow(SwitchServerState()) - val state: StateFlow<SwitchServerState> = _state + val servers = MutableLiveData<List<JellyfinServer>>(listOf()) + val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) + val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf()) - 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 - } + val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf()) + + val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending) fun clearAddServerState() { - _state.update { it.copy(addServerState = LoadingState.Pending) } + addServerState.value = LoadingState.Pending } fun init() { viewModelScope.launchIO { - _state.update { SwitchServerState() } + withContext(Dispatchers.Main) { + serverStatus.value = mapOf() + serverQuickConnect.value = mapOf() + } val allServers = serverDao .getServers() .map { it.server } .sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url }) - .map { ServerState(it) } - _state.update { - it.copy( - loading = LoadingState.Success, - servers = allServers, - ) + withContext(Dispatchers.Main) { + servers.value = allServers } allServers.forEach { server -> - internalTestServer(server.server) + internalTestServer(server) } } } - private fun updateServerState( - 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 if (serverVersion < WholphinApplication.newMinimumServerVersion) { - ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE - } 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, - versionSupported = supported, - ), - ) - } - it.copy(servers = servers) - } - } - fun testServer(server: JellyfinServer) { - updateServerState(server, ServerConnectionStatus.Pending) + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Pending, + ) + } viewModelScope.launchIO { - delay(500) + delay(1000) val result = internalTestServer(server) - if (result is ServerConnectionStatus.Success) { + if (result == ServerConnectionStatus.Success) { showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) } else if (result is ServerConnectionStatus.Error) { showToast(context, result.message ?: "Error", Toast.LENGTH_SHORT) @@ -135,62 +95,46 @@ class SwitchServerViewModel } } - private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus { - val result = - try { - val systemInfo = - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .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) + private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus = + try { + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Success, + ) + } } - updateServerState(server, result) - return result - } - - fun switchServer(server: JellyfinServer) { - viewModelScope.launchIO { - updateServerState(server, ServerConnectionStatus.Pending) - val result = internalTestServer(server) - if (result is ServerConnectionStatus.Success) { - val updatedServer = - server.copy( - name = result.systemInfo.serverName, - version = result.systemInfo.version, - ) - serverRepository.addAndChangeServer(updatedServer) - navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) - } else if (result is ServerConnectionStatus.Error) { - showToast(context, "Error connecting: ${result.message}") + ServerConnectionStatus.Success + } catch (ex: Exception) { + val status = ServerConnectionStatus.Error(ex.localizedMessage) + Timber.w(ex, "Error checking server ${server.url}") + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + status, + ) + } } + status } - } - fun addServer( - inputUrl: String, - showToast: Boolean, - ) { - _state.update { it.copy(addServerState = LoadingState.Loading) } + fun addServer(inputUrl: String) { + addServerState.value = LoadingState.Loading viewModelScope.launchIO { try { val scores = @@ -212,12 +156,27 @@ class SwitchServerViewModel version = serverInfo.version, ) serverRepository.addAndChangeServer(server) - _state.update { it.copy(addServerState = LoadingState.Success) } - navigationManager.navigateTo(SetupDestination.UserList(server)) + val quickConnect = + jellyfin + .createApi(serverUrl) + .quickConnectApi + .getQuickConnectEnabled() + .content + withContext(Dispatchers.Main) { + serverQuickConnect.value = + serverQuickConnect.value!!.toMutableMap().apply { + put(id, quickConnect) + } + } + withContext(Dispatchers.Main) { + addServerState.value = LoadingState.Success + navigationManager.navigateTo(Destination.UserList(server)) + } } else { - val msg = "Server returned invalid response" - _state.update { it.copy(addServerState = LoadingState.Error(msg)) } - if (showToast) showToast(context, msg) + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error("Server returned invalid response") + } } } else { Timber.w("Error connecting with %s: %s", inputUrl, scores) @@ -245,13 +204,14 @@ class SwitchServerViewModel "${it.address} - $issues" } val message = "Error, tried addresses:\n$errors" - _state.update { it.copy(addServerState = LoadingState.Error(message)) } - if (showToast) showToast(context, message) + addServerState.setValueOnMain(LoadingState.Error(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}") + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error(exception = ex) + } } } } @@ -266,48 +226,23 @@ class SwitchServerViewModel fun discoverServers() { viewModelScope.launchIO { jellyfin.discovery.discoverLocalServers().collect { server -> - val jellyfinServer = - JellyfinServer( - server.id.toUUID(), - server.name, - server.address, - null, - ) - _state.update { - it.copy( - discoveredServers = - it.discoveredServers - .toMutableList() - .apply { - add(jellyfinServer) - }.distinctBy { - // Filter out duplicates within the discovered servers list (same URL appearing multiple times) - it.url.lowercase().trim() - }, - ) + val newServerList = + discoveredServers.value!! + .toMutableList() + .apply { + add( + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + null, + ), + ) + } + withContext(Dispatchers.Main) { + discoveredServers.value = newServerList } } } } } - -data class SwitchServerState( - val loading: LoadingState = LoadingState.Pending, - val servers: List<ServerState> = emptyList(), - val discoveredServers: List<JellyfinServer> = emptyList(), - val addServerState: LoadingState = LoadingState.Pending, -) - -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, - NOT_SUPPORTED_IN_FUTURE, -} 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 cfe70890..a33479cb 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.setup import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,22 +11,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.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 @@ -34,79 +34,47 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button 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.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.TextButton -import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState -import kotlinx.coroutines.launch @Composable fun SwitchUserContent( - server: JellyfinServer, + currentServer: JellyfinServer, modifier: Modifier = Modifier, viewModel: SwitchUserViewModel = hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>( - creationCallback = { it.create(server) }, + creationCallback = { it.create(currentServer) }, ), ) { val context = LocalContext.current - val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.init() } - val state by viewModel.state.collectAsState() +// val currentServer by viewModel.serverRepository.currentServer.observeAsState() + val currentUser by viewModel.serverRepository.currentUser.observeAsState() + val users by viewModel.users.observeAsState(listOf()) - val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null) + val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) + val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } - var addUser by remember(server) { mutableStateOf<JellyfinUser?>(null) } - var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") } - fun showAddUserDialog(user: JellyfinUser?) { - addUser = user - showAddUser = true - } - - fun hideAddUserDialog() { - addUser = null - 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) { + val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) + val loginAttempts by viewModel.loginAttempts.observeAsState(0) + LaunchedEffect(userState) { if (!showAddUser) { - when (val s = state.switchUserState) { + when (val s = userState) { is LoadingState.Error -> { val msg = s.message ?: s.exception?.localizedMessage Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show() @@ -116,281 +84,221 @@ fun SwitchUserContent( } } } - var switchUserWithPin by remember { mutableStateOf<JellyfinUser?>(null) } - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - Box( - modifier = modifier.dimAndBlur(showAddUser || switchUserWithPin != null), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(16.dp), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.select_user), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = server.name ?: server.url, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - UserList( - users = state.users, - currentUser = currentUser, - onSwitchUser = { user -> - if (user.accessToken == null || user.requireLogin) { - showAddUserDialog(user) - } else if (user.hasPin) { - switchUserWithPin = user - } else { - trySwitchUser(user) - } - }, - onAddUser = { - showAddUserDialog(null) - }, - onRemoveUser = { user -> - viewModel.removeUser(user) - }, - onSwitchServer = { - viewModel.setupNavigationManager.navigateTo( - SetupDestination.ServerList, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - } - val resources = LocalResources.current - val message = - remember(resources, state.serverVersionSupported) { - resources.serverSupportedMessage( - state.serverVersionSupported, - state.serverVersion, - ) - } - SwitchErrorMessage(message) - } - } - } - - if (showAddUser) { - var useQuickConnect by remember { mutableStateOf(state.quickConnectEnabled) } - LaunchedEffect(Unit) { - viewModel.clearSwitchUserState() - viewModel.resetAttempts() - if (useQuickConnect) { - viewModel.initiateQuickConnect(server, addUser) - } - } - BasicDialog( - onDismissRequest = { - viewModel.cancelQuickConnect() - hideAddUserDialog() - }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), + currentServer?.let { server -> + Box( + modifier = modifier, ) { Column( + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .focusGroup() + .fillMaxWidth(.5f) + .align(Alignment.Center) .padding(16.dp) - .fillMaxWidth(.4f), + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ), ) { + Text( + text = stringResource(R.string.select_user), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = server.name ?: server.url, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + UserList( + users = users, + currentUser = currentUser, + onSwitchUser = { user -> + viewModel.switchUser(user) + }, + onAddUser = { showAddUser = true }, + onRemoveUser = { user -> + viewModel.removeUser(user) + }, + onSwitchServer = { + viewModel.navigationManager.navigateTo(Destination.ServerList) + }, + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(16.dp), + ), + ) + } + } + + if (showAddUser) { + var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } + LaunchedEffect(Unit) { + viewModel.clearSwitchUserState() + viewModel.resetAttempts() if (useQuickConnect) { - if (state.quickConnectStatus == null && state.switchUserState !is LoadingState.Error) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .height(32.dp) - .align(Alignment.CenterHorizontally), - ) { - CircularProgress(Modifier.size(20.dp)) + viewModel.initiateQuickConnect(server) + } + } + BasicDialog( + onDismissRequest = { + viewModel.cancelQuickConnect() + showAddUser = false + }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusGroup() + .padding(16.dp) + .fillMaxWidth(.66f), + ) { + if (useQuickConnect) { + if (quickConnect == null && userState !is LoadingState.Error) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .height(32.dp) + .align(Alignment.CenterHorizontally), + ) { + CircularProgress(Modifier.size(20.dp)) + Text( + text = "Waiting for Quick Connect code...", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier, + ) + } + } else if (quickConnect != null) { Text( - text = "Waiting for Quick Connect code...", + text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, + ) + Text( + text = quickConnect?.code ?: "Failed to get code", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + UserStateError(userState) + Button( + onClick = { + viewModel.cancelQuickConnect() + viewModel.clearSwitchUserState() + useQuickConnect = false + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text(text = "Use username/password") + } + } else { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val onSubmit = { + viewModel.login(server, username, password) + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = "Enter username/password to login to ${server.name ?: server.url}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + UserStateError(userState) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "Username", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { + username = it + viewModel.clearSwitchUserState() + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + isInputValid = { userState !is LoadingState.Error }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "Password", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = password, + onValueChange = { + password = it + viewModel.clearSwitchUserState() + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + keyboardActions = + KeyboardActions( + onDone = { onSubmit.invoke() }, + ), + isInputValid = { userState !is LoadingState.Error }, modifier = Modifier, ) } - } else if (state.quickConnectStatus != null) { + Button( + onClick = { onSubmit.invoke() }, + enabled = username.isNotNullOrBlank() && password.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text("Login") + } + } + if (loginAttempts > 2) { Text( - text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = state.quickConnectStatus?.code ?: "Failed to get code", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onSurface, + text = "Trouble logging in?", modifier = Modifier.align(Alignment.CenterHorizontally), ) - } - UserStateError(state.switchUserState) - TextButton( - stringRes = R.string.username_or_password, - onClick = { - viewModel.cancelQuickConnect() - viewModel.clearSwitchUserState() - useQuickConnect = false - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } else { - var password by remember { mutableStateOf("") } - val onSubmit = { - viewModel.login( - server, - addUser, - username, - password, - ) - } - val focusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (username.isBlank()) { - focusRequester.tryRequestFocus() - } else { - passwordFocusRequester.tryRequestFocus() + Button( + onClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text("Show debug info") } } - Text( - text = "Enter username/password to login to ${server.name ?: server.url}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - UserStateError(state.switchUserState) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.username), - modifier = Modifier.padding(end = 8.dp), - ) - EditTextBox( - value = username, - onValueChange = { username = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - // onKeyboardAction = { -// passwordFocusRequester.tryRequestFocus() -// }, - isInputValid = { state.switchUserState !is LoadingState.Error }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.password), - modifier = Modifier.padding(end = 8.dp), - ) - LaunchedEffect(password) { - viewModel.clearSwitchUserState() - } - EditTextBox( - value = password, - onValueChange = { password = it }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke() }, - ), - isInputValid = { state.switchUserState !is LoadingState.Error }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - TextButton( - stringRes = R.string.login, - onClick = { onSubmit.invoke() }, - enabled = username.isNotNullOrBlank(), - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - if (state.loginAttempts > 2) { - Text( - text = "Trouble logging in?", - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - TextButton( - stringRes = R.string.show_debug_info, - onClick = { - viewModel.navigationManager.navigateTo(Destination.Debug) - }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) } } } } - switchUserWithPin?.let { user -> - PinEntryDialog( - onDismissRequest = { switchUserWithPin = null }, - onClickServerAuth = { - showAddUserDialog(user) - switchUserWithPin = null - }, - onTextChange = { - if (it == user.pin) { - trySwitchUser(user) - } - }, - ) - } } @Composable 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 b11b0b19..56d7ab98 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,45 +1,33 @@ package com.github.damontecres.wholphin.ui.setup -import android.content.Context +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication 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.JellyfinUser -import com.github.damontecres.wholphin.services.ImageUrlService 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.launchDefault import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.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 -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions 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 @@ -49,13 +37,10 @@ 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, val navigationManager: NavigationManager, - val setupNavigationManager: SetupNavigationManager, - val imageUrlService: ImageUrlService, @Assisted val server: JellyfinServer, ) : ViewModel() { @AssistedFactory @@ -63,110 +48,89 @@ class SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel } - private val _state = MutableStateFlow(SwitchUserState()) - val state: StateFlow<SwitchUserState> = _state + val serverQuickConnect = MutableLiveData<Boolean>(false) + + val users = MutableLiveData<List<JellyfinUser>>(listOf()) + val quickConnectState = MutableLiveData<QuickConnectResult?>(null) private var quickConnectJob: Job? = null + val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending) + + val loginAttempts = MutableLiveData(0) + fun clearSwitchUserState() { - _state.update { it.copy(switchUserState = LoadingState.Pending) } + switchUserState.value = LoadingState.Pending } fun resetAttempts() { - _state.update { it.copy(loginAttempts = 0) } + loginAttempts.value = 0 + } + + init { + init() } fun init() { - viewModelScope.launchDefault { + viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { serverRepository.switchServerOrUser() } quickConnectJob?.cancel() viewModelScope.launchIO { - _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) { - Timber.e(ex, "Error fetching users for server $server") - _state.update { - it.copy( - loading = LoadingState.Error(ex), - ) - } + users.setValueOnMain(listOf()) + val serverUsers = + serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf() + withContext(Dispatchers.Main) { + users.setValueOnMain(serverUsers) } } viewModelScope.launchIO { try { + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() val quickConnect by jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).quickConnectApi + .createApi(server.url) + .quickConnectApi .getQuickConnectEnabled() - _state.update { it.copy(quickConnectEnabled = quickConnect) } - } catch (_: CancellationException) { - // no-op, user may have canceled + withContext(Dispatchers.Main) { + serverQuickConnect.value = quickConnect + } } catch (ex: Exception) { Timber.w(ex, "Error checking quick connect for server ${server.url}") - _state.update { it.copy(quickConnectEnabled = false) } + withContext(Dispatchers.Main) { + serverQuickConnect.value = false + } } } } - fun trySwitchUser(user: JellyfinUser): Deferred<SwitchUserResult> = - viewModelScope.async(WholphinDispatchers.IO) { + fun switchUser(user: JellyfinUser) { + viewModelScope.launchIO { try { - val current = serverRepository.changeUser(server, user) - navigationManager.reloadHome() - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - SwitchUserResult.Success - } catch (ex: InvalidStatusException) { - 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) - } + serverRepository.changeUser(server, user) + withContext(Dispatchers.Main) { + navigationManager.goToHome() } } catch (ex: Exception) { Timber.e(ex, "Error switching user") - SwitchUserResult.Error(ex.localizedMessage, true) + setError("Error switching user", ex) } } + } fun login( server: JellyfinServer, - existingUser: JellyfinUser?, username: String, password: String, ) { @@ -178,13 +142,17 @@ class SwitchUserViewModel username = username, password = password, ) - val current = - serverRepository.changeUser(server.url, authenticationResult, existingUser) - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + serverRepository.changeUser(server.url, authenticationResult) + withContext(Dispatchers.Main) { + navigationManager.goToHome() + } } catch (ex: Exception) { Timber.e(ex, "Error logging in user") if (ex is InvalidStatusException && ex.status == 401) { - _state.update { it.copy(switchUserState = LoadingState.Error("Invalid username or password")) } + withContext(Dispatchers.Main) { + switchUserState.value = + LoadingState.Error("Invalid username or password") + } } else { setError("Error during login", ex) } @@ -192,51 +160,46 @@ class SwitchUserViewModel } } - fun initiateQuickConnect( - server: JellyfinServer, - existingUser: JellyfinUser?, - ) { + fun initiateQuickConnect(server: JellyfinServer) { quickConnectJob?.cancel() quickConnectJob = viewModelScope.launchIO { try { val api = jellyfin.createApi(server.url) - var quickConnectStatus = + var state = api .quickConnectApi .initiateQuickConnect() .content - _state.update { it.copy(quickConnectStatus = quickConnectStatus) } - while (!quickConnectStatus.authenticated) { + withContext(Dispatchers.Main) { + quickConnectState.value = state + } + + while (!state.authenticated) { delay(5_000L) - quickConnectStatus = + state = api.quickConnectApi .getQuickConnectState( - secret = quickConnectStatus.secret, + secret = state.secret, ).content - _state.update { it.copy(quickConnectStatus = quickConnectStatus) } + withContext(Dispatchers.Main) { + quickConnectState.value = state + } } val authenticationResult by api.userApi.authenticateWithQuickConnect( - QuickConnectDto(secret = quickConnectStatus.secret), + QuickConnectDto(secret = state.secret), ) - val current = - serverRepository.changeUser( - server.url, - authenticationResult, - existingUser, - ) - setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) - } catch (_: CancellationException) { - // no-op, user may have canceled + serverRepository.changeUser(server.url, authenticationResult) + withContext(Dispatchers.Main) { + navigationManager.goToHome() + } } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") if (ex is InvalidStatusException && ex.status == 401) { - _state.update { - it.copy( - quickConnectEnabled = false, - quickConnectStatus = null, - ) + withContext(Dispatchers.Main) { + quickConnectState.value = null + serverQuickConnect.value = false } } setError("Error with Quick Connect", ex) @@ -246,115 +209,25 @@ class SwitchUserViewModel fun cancelQuickConnect() { quickConnectJob?.cancel() - _state.update { - it.copy( - quickConnectStatus = null, - ) - } + quickConnectState.value = null } fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) - val serverUsers = getUsers() - _state.update { it.copy(users = serverUsers) } + val serverUsers = + serverDao.getServer(user.serverId)?.users?.sortedBy { it.name } ?: listOf() + withContext(Dispatchers.Main) { + users.value = serverUsers + } } } - private suspend fun getUsers(): List<JellyfinUserAndImage> = - withContext(WholphinDispatchers.IO) { - val api = jellyfin.createApi(server.url) - val knownUsers = - serverDao - .getServer(server.id) - ?.users - .orEmpty() - .map { - JellyfinUserAndImage( - user = it, - imageUrl = api.imageApi.getUserImageUrl(it.id), - known = true, - ) - } - val knownUserIds = knownUsers.map { it.user.id } - val publicUsers = - api.userApi - .getPublicUsers() - .content - .map { - JellyfinUser( - id = it.id, - name = it.name, - serverId = server.id, - accessToken = null, - ) - }.filter { it.id !in knownUserIds } - .map { - JellyfinUserAndImage( - user = it, - imageUrl = api.imageApi.getUserImageUrl(it.id), - known = false, - ) - } - - knownUsers + publicUsers - } - - private suspend fun checkServerVersion(): Pair<ServerVersion?, 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) { - serverVersion to ServerVersionSupported.UNKNOWN - } else if (serverVersion < WholphinApplication.minimumServerVersion) { - serverVersion to ServerVersionSupported.NOT_SUPPORTED - } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { - serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE - } else { - serverVersion to ServerVersionSupported.SUPPORTED - } - } - - private fun setError( + private suspend fun setError( msg: String? = null, ex: Exception? = null, - ) { - _state.update { - it.copy( - loginAttempts = it.loginAttempts + 1, - switchUserState = LoadingState.Error(msg, ex), - ) - } + ) = withContext(Dispatchers.Main) { + loginAttempts.value = (loginAttempts.value ?: 0) + 1 + switchUserState.value = LoadingState.Error(msg, ex) } } - -data class JellyfinUserAndImage( - val user: JellyfinUser, - val imageUrl: String?, - val known: Boolean, -) - -data class SwitchUserState( - // LoadingState for fetching available users - val loading: LoadingState = LoadingState.Pending, - val quickConnectEnabled: Boolean = false, - val quickConnectStatus: QuickConnectResult? = null, - val users: List<JellyfinUserAndImage> = emptyList(), - // LoadingState for while adding/switching users - val switchUserState: LoadingState = LoadingState.Pending, - val loginAttempts: Int = 0, - val serverVersion: ServerVersion? = 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/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index 4d3ff8d3..c4fb035e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -1,75 +1,37 @@ package com.github.damontecres.wholphin.ui.setup -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete +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.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties -import androidx.tv.material3.Border -import androidx.tv.material3.Button -import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface +import androidx.tv.material3.ListItem import androidx.tv.material3.Text -import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus -import java.util.UUID /** * Display a list of users plus option to add a new one or switch servers - * Redesigned to match streaming service style with horizontal scrollable user icons */ @Composable fun UserList( - users: List<JellyfinUserAndImage>, + users: List<JellyfinUser>, currentUser: JellyfinUser?, onSwitchUser: (JellyfinUser) -> Unit, onAddUser: () -> Unit, @@ -77,364 +39,88 @@ fun UserList( onSwitchServer: () -> Unit, modifier: Modifier = Modifier, ) { - var showDeleteDialog by remember { mutableStateOf<JellyfinUserAndImage?>(null) } + var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) } - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // Horizontal scrollable list of user icons - centered - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - val listState = rememberLazyListState() - val initialFocusRequester = remember { FocusRequester() } - val initialIndex = - users.indexOfFirst { it.user.id == currentUser?.id }.takeIf { it >= 0 } ?: 0 - LaunchedEffect(users, currentUser?.id) { - if (users.isNotEmpty()) { - listState.scrollToItem(initialIndex) - initialFocusRequester.tryRequestFocus() - } - } - LazyRow( - state = listState, - horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale - contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale - modifier = - Modifier - .wrapContentWidth() - .focusRestorer(initialFocusRequester), - ) { - itemsIndexed(users) { index, user -> - UserIconCard( - user = user, - isCurrentUser = user.user.id == currentUser?.id, - onClick = { onSwitchUser.invoke(user.user) }, - onLongClick = { showDeleteDialog = user }, - modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier, - ) - } - // Add User card - always rightmost - item { - AddUserCard( - onClick = { onAddUser.invoke() }, - ) - } - } + LazyColumn(modifier = modifier) { + items(users) { user -> + ListItem( + enabled = true, + selected = user == currentUser, + headlineContent = { Text(text = user.name ?: user.id.toString()) }, + leadingContent = { + if (user.id == currentUser?.id) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "current user", + ) + } + }, + onClick = { onSwitchUser.invoke(user) }, + onLongClick = { + showDeleteDialog = user + }, + modifier = Modifier, + ) } - - // Switch servers button below user list - centered - Row( - horizontalArrangement = Arrangement.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Button( - onClick = { onSwitchServer.invoke() }, - modifier = Modifier.width(200.dp), // Fixed width for consistency - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = stringResource(R.string.add_user)) }, + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + tint = Color.Green.copy(alpha = .8f), + contentDescription = null, + ) + }, + onClick = { onAddUser.invoke() }, + modifier = Modifier, + ) + } + item { + HorizontalDivider() + ListItem( + enabled = true, + selected = false, + headlineContent = { Text(text = stringResource(R.string.switch_servers)) }, + leadingContent = { Text( text = stringResource(R.string.fa_arrow_left_arrow_right), fontFamily = FontAwesome, - modifier = Modifier.padding(end = 8.dp), ) - Text( - text = stringResource(R.string.switch_servers), - textAlign = TextAlign.Center, - ) - } - } + }, + onClick = { onSwitchServer.invoke() }, + modifier = Modifier, + ) } } showDeleteDialog?.let { user -> DialogPopup( showDialog = true, - title = user.user.name ?: user.user.id.toServerString(), + title = user.name ?: user.id.toString(), dialogItems = - buildList { - add( - DialogItem( - stringResource(R.string.switch_user), - R.string.fa_arrow_left_arrow_right, - ) { - onSwitchUser.invoke(user.user) - }, - ) - if (user.known) { - add( - DialogItem( - stringResource(R.string.delete), - Icons.Default.Delete, - Color.Red.copy(alpha = .8f), - ) { - onRemoveUser.invoke(user.user) - }, - ) - } - }, + listOf( + DialogItem( + stringResource(R.string.switch_user), + R.string.fa_arrow_left_arrow_right, + ) { + onSwitchUser.invoke(user) + }, + DialogItem( + stringResource(R.string.delete), + Icons.Default.Delete, + Color.Red.copy(alpha = .8f), + ) { + onRemoveUser.invoke(user) + }, + ), onDismissRequest = { showDeleteDialog = null }, dismissOnClick = true, waitToLoad = true, properties = DialogProperties(), - ) - } -} - -@Composable -private fun UserIconCard( - user: JellyfinUserAndImage, - isCurrentUser: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - // Generate unique color for this user - val userColor = rememberIdColor(user.user.id) - - // Track image loading errors - var imageError by remember { mutableStateOf(false) } - - // Card dimensions - circular card - val cardSize = Cards.serverUserCircle - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - // Circular card with colored background - Surface( - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - modifier = Modifier.size(cardSize), - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = - if (isCurrentUser) { - userColor.copy(alpha = 0.7f) - } else { - userColor.copy(alpha = 0.5f) - }, - focusedContainerColor = - if (isCurrentUser) { - userColor.copy(alpha = 0.9f) - } else { - userColor.copy(alpha = 0.7f) - }, - ), - border = - ClickableSurfaceDefaults.border( - focusedBorder = - Border( - border = - BorderStroke( - width = 3.dp, - color = MaterialTheme.colorScheme.onSurface, - ), - shape = CircleShape, - ), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (user.imageUrl.isNotNullOrBlank() && !imageError) { - AsyncImage( - model = user.imageUrl, - contentDescription = user.user.name, - contentScale = ContentScale.Crop, - onError = { imageError = true }, - modifier = - Modifier - .fillMaxSize() - .clip(CircleShape), - ) - } else { - // Show big bold first letter of username - val firstLetter = - remember(user) { - user.user.let { - (it.name ?: it.id.toString()).firstOrNull()?.uppercase() - } ?: "?" - } - Text( - text = firstLetter, - style = - MaterialTheme.typography.displayLarge.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - } - } - - // Username below the card - Text( - text = user.user.name ?: user.user.id.toString(), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .width(cardSize) - .padding(horizontal = 4.dp), - ) - } -} - -@Composable -fun UserIconCardImage( - id: UUID, - name: String?, - imageUrl: String?, - modifier: Modifier = Modifier, - alpha: Float = 1f, -) { - var imageError by remember { mutableStateOf(false) } - val userColor = rememberIdColor(id, alpha) - Box( - modifier = - modifier.background( - color = userColor, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - if (imageUrl.isNotNullOrBlank() && !imageError) { - AsyncImage( - model = imageUrl, - contentDescription = name, - contentScale = ContentScale.Crop, - onError = { imageError = true }, - modifier = - Modifier - .fillMaxSize() - .clip(CircleShape), - ) - } else { - val firstLetter = - remember(id, name) { - (name ?: id.toString()).firstOrNull()?.uppercase() ?: "?" - } - Text( - text = firstLetter, - style = MaterialTheme.typography.bodyLarge, - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) - } - } -} - -@PreviewTvSpec -@Composable -fun UserIconCardImagePreview() { - WholphinTheme { - UserIconCardImage( - id = UUID.randomUUID(), - name = "A smith", - imageUrl = null, - modifier = Modifier.size(24.dp), - ) - } -} - -/** - * Add User card component - displays a + icon in a circle - */ -@Composable -private fun AddUserCard( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - - // Use a neutral gray color for the add user card - val addUserColor = MaterialTheme.colorScheme.surfaceVariant - - // Card dimensions - circular card (same as user cards) - val cardSize = Cards.height2x3 * 0.75f // ~120dp - - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), // Increased to accommodate 20% scale - ) { - // Circular card with colored background - Surface( - onClick = onClick, - interactionSource = interactionSource, - modifier = Modifier.size(cardSize), - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - colors = - ClickableSurfaceDefaults.colors( - containerColor = addUserColor.copy(alpha = 0.4f), - focusedContainerColor = addUserColor.copy(alpha = 0.6f), - ), - border = - ClickableSurfaceDefaults.border( - focusedBorder = - Border( - border = - BorderStroke( - width = 3.dp, - color = MaterialTheme.colorScheme.onSurface, - ), - shape = CircleShape, - ), - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.2f), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_user), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(cardSize * 0.4f), // Size of the + icon - ) - } - } - - // "Add User" text below the card - Text( - text = stringResource(R.string.add_user), - style = - MaterialTheme.typography.bodyLarge.copy( - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .width(cardSize) - .padding(horizontal = 4.dp), + elevation = 5.dp, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt deleted file mode 100644 index 5faccc05..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.github.damontecres.wholphin.ui.setup.seerr - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -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 -import androidx.compose.ui.focus.focusRequester -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.unit.dp -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.seerrUrlPrefill -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.components.Button -import com.github.damontecres.wholphin.ui.components.EditTextBox -import com.github.damontecres.wholphin.ui.components.TextButton -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.LoadingState - -@Composable -fun AddSeerrServerApiKey( - initialUrl: String, - onSubmit: (url: String, apiKey: String) -> Unit, - status: LoadingState, - modifier: Modifier = Modifier, -) { - var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .focusGroup() - .padding(16.dp) - .wrapContentSize(), - ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } - var apiKey by remember { mutableStateOf("") } - - val focusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (url.isBlank()) { - focusRequester.tryRequestFocus() - } else { - passwordFocusRequester.tryRequestFocus() - } - } - Text( - text = stringResource(R.string.enter_url_api_key), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - val labelWidth = 90.dp - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.url), - modifier = - Modifier - .width(labelWidth) - .padding(end = 8.dp), - ) - EditTextBox( - value = url, - onValueChange = { - error = null - url = it - }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - isInputValid = { true }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.api_key), - modifier = - Modifier - .width(labelWidth) - .padding(end = 8.dp), - ) - EditTextBox( - value = apiKey, - onValueChange = { - error = null - apiKey = it - }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke(url, apiKey) }, - ), - isInputValid = { true }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - error?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.titleLarge, - ) - } - TextButton( - stringRes = R.string.submit, - onClick = { onSubmit.invoke(url, apiKey) }, - enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(), - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } -} - -@Composable -fun AddSeerrServerUsername( - initialUrl: String, - onSubmit: (url: String, username: String, password: String) -> Unit, - username: String, - status: LoadingState, - modifier: Modifier = Modifier, -) { - var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .focusGroup() - .padding(16.dp) - .wrapContentSize(), - ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } - var username by remember { mutableStateOf(username) } - var password by remember { mutableStateOf("") } - - val focusRequester = remember { FocusRequester() } - val usernameFocusRequester = remember { FocusRequester() } - val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - val focusRequesterToUse = - when { - url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester - url.isNotNullOrBlank() -> usernameFocusRequester - else -> focusRequester - } - focusRequesterToUse.tryRequestFocus() - } - Text( - text = stringResource(R.string.username_or_password), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - val labelWidth = 90.dp - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.url), - color = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier - .width(labelWidth) - .padding(end = 8.dp), - ) - EditTextBox( - value = url, - onValueChange = { - error = null - url = it - }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - usernameFocusRequester.tryRequestFocus() - }, - ), - isInputValid = { true }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.username), - color = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier - .width(labelWidth) - .padding(end = 8.dp), - ) - EditTextBox( - value = username, - onValueChange = { - error = null - username = it - }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - keyboardActions = - KeyboardActions( - onNext = { - passwordFocusRequester.tryRequestFocus() - }, - ), - isInputValid = { true }, - modifier = Modifier.focusRequester(usernameFocusRequester), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(R.string.password), - color = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier - .width(labelWidth) - .padding(end = 8.dp), - ) - EditTextBox( - value = password, - onValueChange = { - error = null - password = it - }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions( - onGo = { onSubmit.invoke(url, username, password) }, - ), - isInputValid = { true }, - modifier = Modifier.focusRequester(passwordFocusRequester), - ) - } - error?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.titleLarge, - ) - } - Button( - onClick = { onSubmit.invoke(url, username, password) }, - enabled = - error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && - status != LoadingState.Loading, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) { - if (status != LoadingState.Loading) { - Text(text = stringResource(R.string.submit)) - } else { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = - Modifier.size(24.dp), - ) - } - } - } -} - -@PreviewTvSpec -@Composable -private fun AddSeerrServerUsernamePreview() { - WholphinTheme { - AddSeerrServerUsername( - initialUrl = "", - onSubmit = { string: String, string1: String, string2: String -> }, - username = "test", - status = LoadingState.Pending, - modifier = Modifier, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt deleted file mode 100644 index 5be93b83..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.github.damontecres.wholphin.ui.setup.seerr - -import android.widget.Toast -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser -import com.github.damontecres.wholphin.services.CurrentSeerr -import com.github.damontecres.wholphin.services.SeerrState -import com.github.damontecres.wholphin.ui.components.BasicDialog -import com.github.damontecres.wholphin.ui.components.ConfirmDialog -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.LoadingPage -import com.github.damontecres.wholphin.ui.components.ScrollableDialog -import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody -import com.github.damontecres.wholphin.util.LoadingState - -@Composable -fun SeerrDialog( - seerrDialogMode: SeerrDialogMode, - seerrConnection: SeerrState, - onDismissRequest: () -> Unit, - seerrVm: SwitchSeerrViewModel = hiltViewModel(), -) { - val context = LocalContext.current - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) - if (seerrConnection.pluginProvided) { - ScrollableDialog( - onDismissRequest = onDismissRequest, - ) { - item { - Text(body) - } - } - } else { - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = onDismissRequest, - onConfirm = { - seerrVm.removeServer() - onDismissRequest.invoke() - }, - elevation = 3.dp, - ) - } - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.collectAsState(null) - val state by seerrVm.state.collectAsState() - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(state.serverConnectionStatus) { - if (state.serverConnectionStatus == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - onDismissRequest.invoke() - } - } - when (state.loading) { - is LoadingState.Error, - LoadingState.Loading, - LoadingState.Pending, - -> { - BasicDialog( - onDismissRequest = onDismissRequest, - ) { - LoadingPage(Modifier.padding(16.dp)) - } - } - - LoadingState.Success -> { - AddSeerServerDialog( - initialUrl = state.initialUrl, - currentUsername = currentUser?.name, - status = state.serverConnectionStatus, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = onDismissRequest, - ) - } - } - } - - is SeerrDialogMode.Error -> { - val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) - if (seerrConnection.pluginProvided) { - ConfirmDialog( - title = stringResource(R.string.error), - body = body, - onCancel = onDismissRequest, - onConfirm = { - seerrVm.retryServerFromPlugin() - onDismissRequest.invoke() - }, - bodyColor = MaterialTheme.colorScheme.error, - elevation = 3.dp, - confirmStringRes = R.string.retry, - ) - } else { - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = onDismissRequest, - onConfirm = { - seerrVm.removeServer() - onDismissRequest.invoke() - }, - bodyColor = MaterialTheme.colorScheme.error, - elevation = 3.dp, - ) - } - } - - SeerrDialogMode.None -> {} - } -} - -sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val current: CurrentSeerr, - ) : SeerrDialogMode - - data class Error( - val server: SeerrServer, - val user: SeerrUser, - val ex: Exception, - ) : SeerrDialogMode -} - -@Composable -fun AddSeerServerDialog( - currentUsername: String?, - initialUrl: String?, - status: LoadingState, - onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, - onResetStatus: () -> Unit, - onDismissRequest: () -> Unit, -) { - var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } - LaunchedEffect(status) { - if (status is LoadingState.Success) { - onDismissRequest.invoke() - } - } - when (val auth = authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - BasicDialog( - onDismissRequest = { authMethod = null }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - AddSeerrServerUsername( - initialUrl = initialUrl ?: "", - onSubmit = { url, username, password -> - onSubmit.invoke(url, username, password, auth) - }, - username = currentUsername ?: "", - status = status, - modifier = Modifier.widthIn(min = 320.dp), - ) - } - } - - SeerrAuthMethod.API_KEY -> { - BasicDialog( - onDismissRequest = { authMethod = null }, - ) { - AddSeerrServerApiKey( - initialUrl = initialUrl ?: "", - onSubmit = { url, apiKey -> - onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) - }, - status = status, - modifier = Modifier.widthIn(min = 320.dp), - ) - } - } - - null -> { - ChooseSeerrLoginType( - onDismissRequest = onDismissRequest, - onChoose = { - onResetStatus.invoke() - authMethod = it - }, - ) - } - } -} - -@Composable -fun ChooseSeerrLoginType( - onDismissRequest: () -> Unit, - onChoose: (SeerrAuthMethod) -> Unit, -) { - val params = - DialogParams( - fromLongClick = false, - title = stringResource(R.string.seerr_login), - items = - listOf( - DialogItem( - text = stringResource(R.string.seerr_jellyfin_user), - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = stringResource(R.string.seerr_local_user), - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), - DialogItem( - text = stringResource(R.string.api_key), - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - ), - ) - - DialogPopup( - params = params, - onDismissRequest = onDismissRequest, - dismissOnClick = false, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt deleted file mode 100644 index 2fde3eae..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ /dev/null @@ -1,225 +0,0 @@ -package com.github.damontecres.wholphin.ui.setup.seerr - -import android.content.Context -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.ServerRepository -import com.github.damontecres.wholphin.data.model.SeerrAuthMethod -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.services.ServerPluginApi -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.LoadingState -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import jakarta.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import timber.log.Timber - -@HiltViewModel -class SwitchSeerrViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val seerrServerRepository: SeerrServerRepository, - private val seerrService: SeerrService, - private val serverRepository: ServerRepository, - private val serverPluginApi: ServerPluginApi, - ) : ViewModel() { - val currentUser = serverRepository.currentUserFlow - val currentSeerrServer = seerrServerRepository.currentServer - - private val _state = MutableStateFlow<SwitchSeerrState>(SwitchSeerrState()) - val state: StateFlow<SwitchSeerrState> = _state - - init { - if (serverRepository.serverPluginInstalled.value) { - viewModelScope.launchIO { - try { - serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> - _state.update { it.copy(initialUrl = serverUrl) } - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching seerr settings") - } finally { - _state.update { it.copy(loading = LoadingState.Success) } - } - } - } - } - - fun submitServer( - url: String, - username: String, - passwordOrApiKey: String, - authMethod: SeerrAuthMethod, - ) { - viewModelScope.launchIO { - _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } - val urls = - try { - createUrls(url) - } catch (ex: IllegalArgumentException) { - showToast(context, "Invalid URL") - _state.update { - it.copy( - serverConnectionStatus = - LoadingState.Error( - "Invalid URL", - ex, - ), - ) - } - return@launchIO - } - Timber.v("Urls to try: %s", urls) - val results = mutableMapOf<HttpUrl, LoadingState>() - for (url in urls) { - Timber.d("Trying %s", url) - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - results[url] = LoadingState.Success - break - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in %s", url) - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - results[url] = LoadingState.Error("Invalid credentials", ex) - } else { - results[url] = LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "ClientException logging in %s", url) - results[url] = LoadingState.Error(ex) - } - } - val result = results.filter { (url, state) -> state is LoadingState.Success } - if (result.isNotEmpty()) { - val url = result.keys.first() - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } - _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } - } else { - val message = - results - .map { (url, state) -> - val s = state as? LoadingState.Error - "$url - ${s?.localizedMessage}" - }.joinToString("\n") - showToast(context, "Could not connect") - _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } - } - } - } - - fun removeServer() { - viewModelScope.launchIO { - val result = seerrServerRepository.removeServerForCurrentUser() - if (!result) { - showToast(context, "Could not remove server") - } - } - } - - fun retryServerFromPlugin() { - viewModelScope.launchIO { - val settings = serverPluginApi.fetchSeerrSettings() - if (settings != null) { - val result = seerrServerRepository.usePluginProvidedServer(settings) - if (!result) { - showToast(context, context.getString(R.string.voice_error_server)) - } - } else { - seerrServerRepository.clear() - } - } - } - - fun resetStatus() { - _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } - } - } - -data class SwitchSeerrState( - val loading: LoadingState = LoadingState.Pending, - val serverConnectionStatus: LoadingState = LoadingState.Pending, - val initialUrl: String? = null, -) - -fun createUrls(url: String): List<HttpUrl> { - val urls = mutableListOf<HttpUrl>() - if (url.startsWith("http://") || url.startsWith("https://")) { - val httpUrl = url.toHttpUrl() - urls.add(httpUrl) - if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { - urls.add(httpUrl.newBuilder().port(5055).build()) - } - } else { - val httpUrl = "http://$url".toHttpUrl() - urls.add(httpUrl) - if (httpUrl.port == 80) { - urls.add(httpUrl.newBuilder().scheme("https").build()) - urls.add(httpUrl.newBuilder().port(5055).build()) - urls.add( - httpUrl - .newBuilder() - .scheme("https") - .port(5055) - .build(), - ) - } else { - urls.add(httpUrl.newBuilder().scheme("https").build()) - } - } - return urls -} - -fun createSeerrApiUrl(url: String): String = - if (url.isBlank()) { - url - } else if (url.endsWith("/api/v1") || url.endsWith("/api/v1/")) { - url - } else { - url - .toHttpUrl() - .newBuilder() - .addPathSegment("api") - .addPathSegment("v1") - .build() - .toString() - } - -fun migrateSeerrUrl(url: String): String { - var url = url.removeSuffix("/api/v1/").removeSuffix("/api/v1") - if (!url.endsWith("/")) url += "/" - return url -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt deleted file mode 100644 index 1b9555c6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import androidx.annotation.OptIn -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusState -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.media3.common.util.UnstableApi -import androidx.tv.material3.Button -import androidx.tv.material3.ButtonDefaults -import androidx.tv.material3.Icon -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import kotlinx.coroutines.launch -import kotlin.time.Duration - -@OptIn(UnstableApi::class) -@Composable -fun ImageControlsOverlay( - slideshowEnabled: Boolean, - slideshowControls: SlideshowControls, - onDismiss: () -> Unit, - isImageClip: Boolean, - onZoom: (Float) -> Unit, - onRotate: (Int) -> Unit, - onReset: () -> Unit, - moreOnClick: () -> Unit, - isPlaying: Boolean, - playPauseOnClick: () -> Unit, - onShowFilterDialogClick: () -> Unit, - bringIntoViewRequester: BringIntoViewRequester?, - modifier: Modifier = Modifier, -) { - val focusRequester = remember { FocusRequester() } - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - val onFocused = { focusState: FocusState -> - if (focusState.isFocused && bringIntoViewRequester != null) { - scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() } - } - } - - LazyRow( - modifier = - modifier - .focusGroup(), - contentPadding = PaddingValues(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - item { - ExpandablePlayButton( - title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow, - icon = - painterResource( - if (slideshowEnabled) { - R.drawable.baseline_pause_24 - } else { - R.drawable.baseline_play_arrow_24 - }, - ), - resume = Duration.ZERO, - onClick = { - if (slideshowEnabled) { - slideshowControls.stopSlideshow() - } else { - slideshowControls.startSlideshow() - onDismiss.invoke() - } - }, - modifier = - Modifier - .focusRequester(focusRequester) - .onFocusChanged(onFocused), - ) - } - if (isImageClip) { - item { - Button( - onClick = playPauseOnClick, - modifier = - Modifier - .onFocusChanged(onFocused), - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - ) { - Icon( - painter = - painterResource( - if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, - ), - contentDescription = null, - ) - } - } - } else { - // Regular image - item { - ExpandableFaButton( - title = R.string.rotate_left, - iconStringRes = R.string.fa_rotate_left, - onClick = { onRotate(-90) }, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - item { - ExpandableFaButton( - title = R.string.rotate_right, - iconStringRes = R.string.fa_rotate_right, - onClick = { onRotate(90) }, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - item { - ExpandableFaButton( - title = R.string.zoom_in, - iconStringRes = R.string.fa_magnifying_glass_plus, - onClick = { onZoom(.15f) }, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - item { - ExpandableFaButton( - title = R.string.zoom_out, - iconStringRes = R.string.fa_magnifying_glass_minus, - onClick = { onZoom(-.15f) }, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - item { - ExpandableFaButton( - title = R.string.reset, - iconStringRes = R.string.fa_arrows_rotate, - onClick = onReset, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - item { - ExpandableFaButton( - title = R.string.filter, - iconStringRes = R.string.fa_sliders, - onClick = onShowFilterDialogClick, - modifier = - Modifier - .onFocusChanged(onFocused), - ) - } - } - // More button -// item { -// ExpandablePlayButton( -// title = R.string.more, -// resume = Duration.ZERO, -// icon = Icons.Default.MoreVert, -// onClick = { moreOnClick.invoke() }, -// modifier = Modifier.onFocusChanged(onFocused), -// ) -// } - } -} - -@Preview(widthDp = 800) -@Composable -private fun ImageControlsOverlayPreview() { - WholphinTheme { - ImageControlsOverlay( - slideshowEnabled = true, - slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - } - - override fun stopSlideshow() { - } - }, - isImageClip = false, - onZoom = {}, - onRotate = {}, - onReset = {}, - moreOnClick = {}, - isPlaying = false, - playPauseOnClick = {}, - bringIntoViewRequester = null, - onShowFilterDialogClick = {}, - onDismiss = {}, - modifier = Modifier, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt deleted file mode 100644 index 9ac3b6b1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import androidx.annotation.OptIn -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetails -import com.github.damontecres.wholphin.ui.components.StreamLabel -import com.github.damontecres.wholphin.ui.components.VideoStreamDetails -import org.jellyfin.sdk.model.api.MediaType - -@OptIn(UnstableApi::class) -@Composable -fun ImageDetailsHeader( - slideshowEnabled: Boolean, - slideshowControls: SlideshowControls, - player: Player, - image: ImageState, - position: Int, - count: Int, - moreOnClick: () -> Unit, - onZoom: (Float) -> Unit, - onRotate: (Int) -> Unit, - onReset: () -> Unit, - onShowFilterDialogClick: () -> Unit, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .bringIntoViewRequester(bringIntoViewRequester), - ) { - image.image.title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } - QuickDetails(image.image.ui.quickDetails, null) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - StreamLabel("${position + 1} of $count") - if (image.image.data.mediaType == MediaType.VIDEO) { - VideoStreamDetails( - chosenStreams = image.chosenStreams, - numberOfVersions = 0, - ) - } else { - image.image.data.let { - if (it.width != null && it.height != null) { - StreamLabel("${it.width}x${it.height}") - } - } - } - } - OverviewText( - overview = image.image.data.overview ?: "", - maxLines = 3, - onClick = {}, - modifier = Modifier.fillMaxWidth(.75f), - ) - val playPauseState = rememberPlayPauseButtonState(player) - ImageControlsOverlay( - slideshowEnabled = slideshowEnabled, - slideshowControls = slideshowControls, - isImageClip = image.image.data.mediaType == MediaType.VIDEO, - bringIntoViewRequester = bringIntoViewRequester, - onZoom = onZoom, - onRotate = onRotate, - onReset = onReset, - moreOnClick = moreOnClick, - playPauseOnClick = playPauseState::onClick, - isPlaying = playPauseState.showPlay, - onShowFilterDialogClick = onShowFilterDialogClick, - onDismiss = {}, - modifier = - Modifier - .fillMaxWidth(), - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt deleted file mode 100644 index d07f054a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt +++ /dev/null @@ -1,296 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import android.view.Gravity -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.DialogWindowProvider -import androidx.tv.material3.Button -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.VideoFilter -import com.github.damontecres.wholphin.ui.components.SliderBar -import com.github.damontecres.wholphin.ui.components.SliderColors -import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -const val DRAG_THROTTLE_DELAY = 50L - -@Composable -fun ImageFilterSliders( - filter: VideoFilter, - showVideoOptions: Boolean, - showSaveButton: Boolean, - showSaveGalleryButton: Boolean, - onChange: (VideoFilter) -> Unit, - onClickSave: () -> Unit, - onClickSaveGallery: () -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - item { - SliderBarRow( - title = R.string.brightness, - value = filter.brightness, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(brightness = it)) }, - valueFormater = { "$it%" }, - ) - } - item { - SliderBarRow( - title = R.string.contrast, - value = filter.contrast, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(contrast = it)) }, - valueFormater = { "$it%" }, - ) - } - item { - SliderBarRow( - title = R.string.saturation, - value = filter.saturation, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(saturation = it)) }, - valueFormater = { "$it%" }, - ) - } - if (showVideoOptions) { - item { - SliderBarRow( - title = R.string.hue, - value = filter.hue, - min = 0, - max = 360, - onChange = { onChange.invoke(filter.copy(hue = it)) }, - valueFormater = { "$it\u00b0" }, - ) - } - } - item { - SliderBarRow( - title = R.string.red, - value = filter.red, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(red = it)) }, - valueFormater = { "$it%" }, - colors = - SliderColors( - activeFocused = Color.Red.copy(alpha = .75f), - activeUnfocused = Color.Red.copy(alpha = .75f), - inactiveFocused = Color.Red.copy(alpha = .33f), - inactiveUnfocused = Color.Red.copy(alpha = .33f), - ), - ) - } - item { - SliderBarRow( - title = R.string.green, - value = filter.green, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(green = it)) }, - valueFormater = { "$it%" }, - colors = - SliderColors( - activeFocused = Color.Green.copy(alpha = .75f), - activeUnfocused = Color.Green.copy(alpha = .75f), - inactiveFocused = Color.Green.copy(alpha = .33f), - inactiveUnfocused = Color.Green.copy(alpha = .33f), - ), - ) - } - item { - SliderBarRow( - title = R.string.blue, - value = filter.blue, - min = 0, - max = 200, - onChange = { onChange.invoke(filter.copy(blue = it)) }, - valueFormater = { "$it%" }, - colors = - SliderColors( - activeFocused = Color.Blue.copy(alpha = .75f), - activeUnfocused = Color.Blue.copy(alpha = .75f), - inactiveFocused = Color.Blue.copy(alpha = .33f), - inactiveUnfocused = Color.Blue.copy(alpha = .33f), - ), - ) - } - if (showVideoOptions) { - item { - SliderBarRow( - title = R.string.blur, - value = filter.blur, - min = 0, - max = 250, - onChange = { onChange.invoke(filter.copy(blur = it)) }, - valueFormater = { "${it}px" }, - ) - } - } - item { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - if (showSaveButton) { - Button( - onClick = onClickSave, - ) { - Text(text = stringResource(R.string.save)) - } - } - if (showSaveGalleryButton) { - Button( - onClick = onClickSaveGallery, - ) { - Text(text = stringResource(R.string.save_for_album)) - } - } - Button( - onClick = { onChange(VideoFilter()) }, - ) { - Text(text = stringResource(R.string.reset)) - } - } - } - } - } -} - -@Composable -fun SliderBarRow( - @StringRes title: Int, - value: Int, - min: Int, - max: Int, - onChange: (Int) -> Unit, - valueFormater: (Int) -> String, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - interval: Int = 1, - colors: SliderColors = SliderColors.default(), -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - Text( - text = stringResource(title), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(96.dp), - ) - SliderBar( - value = value.toLong(), - min = min.toLong(), - max = max.toLong(), - interval = interval, - onChange = { - onChange.invoke(it.toInt()) - }, - colors = colors, - interactionSource = interactionSource, - enableWrapAround = true, - modifier = Modifier.weight(1f), - ) - Text( - text = valueFormater(value), - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.width(48.dp), - ) - } -} - -@Composable -fun ImageFilterDialog( - filter: VideoFilter, - showVideoOptions: Boolean, - showSaveGalleryButton: Boolean, - onChange: (VideoFilter) -> Unit, - onClickSave: () -> Unit, - onClickSaveGallery: () -> Unit, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider - dialogWindowProvider?.window?.let { window -> - window.setGravity(Gravity.TOP or Gravity.END) - window.setDimAmount(0f) - } - - Box( - modifier = - modifier - .wrapContentSize() - .padding(8.dp) - .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f)) - .fillMaxWidth(.4f), - ) { - ImageFilterSliders( - filter = filter, - showVideoOptions = showVideoOptions, - showSaveButton = true, - showSaveGalleryButton = showSaveGalleryButton, - onChange = onChange, - onClickSave = onClickSave, - onClickSaveGallery = onClickSaveGallery, - modifier = Modifier.padding(8.dp), - ) - } - } -} - -@Preview -@Composable -private fun ImageFilterSlidersPreview() { - WholphinTheme { - ImageFilterSliders( - filter = VideoFilter(), - showVideoOptions = true, - onChange = {}, - onClickSave = {}, - onClickSaveGallery = {}, - showSaveButton = true, - showSaveGalleryButton = true, - modifier = Modifier.padding(8.dp), - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt deleted file mode 100644 index a6b16ff0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.github.damontecres.wholphin.ui.components.CircularProgress -import com.github.damontecres.wholphin.ui.isNotNullOrBlank - -@Composable -fun ImageLoadingPlaceholder( - thumbnailUrl: String?, - showThumbnail: Boolean, - colorFilter: ColorFilter?, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier) { - if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) { - AsyncImage( - model = - ImageRequest - .Builder(LocalContext.current) - .data(thumbnailUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - modifier = - Modifier - .fillMaxSize() - .align(Alignment.Center) - .alpha(.75f) - .blur(4.dp), - ) - } - CircularProgress( - Modifier - .size(80.dp) - .align(Alignment.Center), - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt deleted file mode 100644 index b177be74..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.tv.material3.Icon -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.components.DialogItem -import com.github.damontecres.wholphin.ui.components.DialogParams -import com.github.damontecres.wholphin.ui.components.DialogPopup - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun ImageOverlay( - onDismiss: () -> Unit, - player: Player, - slideshowControls: SlideshowControls, - slideshowEnabled: Boolean, - position: Int, - count: Int, - image: ImageState, - onClickItem: (BaseItem) -> Unit, - onLongClickItem: (BaseItem) -> Unit, - onZoom: (Float) -> Unit, - onRotate: (Int) -> Unit, - onReset: () -> Unit, - onShowFilterDialogClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - var showDialog by remember { mutableStateOf<DialogParams?>(null) } - - val moreDialogParams = - remember { - DialogParams( - fromLongClick = false, - title = "TODO", - items = - listOf( - DialogItem( - headlineContent = { - Text( - text = - if (slideshowEnabled) { - stringResource(R.string.stop_slideshow) - } else { - stringResource(R.string.play_slideshow) - }, - ) - }, - leadingContent = { - val icon = - if (slideshowEnabled) { - R.drawable.baseline_pause_24 - } else { - R.drawable.baseline_play_arrow_24 - } - Icon( - painter = painterResource(icon), - contentDescription = null, - ) - }, - onClick = { - if (slideshowEnabled) { - slideshowControls.stopSlideshow() - } else { - slideshowControls.startSlideshow() - } - }, - ), - DialogItem( - headlineContent = { - Text( - text = stringResource(R.string.filter), - ) - }, - leadingContent = { - Text( - text = stringResource(R.string.fa_sliders), - fontFamily = FontAwesome, - ) - }, - onClick = onShowFilterDialogClick, - ), - ), - ) - } - - val horizontalPadding = 16.dp - LazyColumn( - contentPadding = - PaddingValues( - start = horizontalPadding, - end = horizontalPadding, - top = 16.dp, - bottom = 16.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - item { - ImageDetailsHeader( - onDismiss = onDismiss, - slideshowEnabled = slideshowEnabled, - slideshowControls = slideshowControls, - player = player, - image = image, - position = position, - count = count, - moreOnClick = { - showDialog = moreDialogParams - }, - onZoom = onZoom, - onRotate = onRotate, - onReset = onReset, - onShowFilterDialogClick = onShowFilterDialogClick, - modifier = Modifier.fillMaxWidth(), - ) - } - } - showDialog?.let { params -> - DialogPopup( - showDialog = true, - title = params.title, - dialogItems = params.items, - onDismissRequest = { showDialog = null }, - waitToLoad = params.fromLongClick, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt deleted file mode 100644 index 91dc2543..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ /dev/null @@ -1,511 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import android.annotation.SuppressLint -import android.widget.Toast -import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorMatrixColorFilter -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.compose.PlayerSurface -import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW -import androidx.media3.ui.compose.modifiers.resizeWithContentScale -import androidx.media3.ui.compose.state.rememberPresentationState -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import coil3.annotation.ExperimentalCoilApi -import coil3.compose.SubcomposeAsyncImage -import coil3.compose.useExistingImageAsPlaceholder -import coil3.request.ImageRequest -import coil3.request.transitionFactory -import coil3.size.Size -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.CrossFadeFactory -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad -import com.github.damontecres.wholphin.ui.playback.isDpad -import com.github.damontecres.wholphin.ui.playback.isEnterKey -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.LoadingState -import org.jellyfin.sdk.model.api.MediaType -import timber.log.Timber -import kotlin.math.abs -import kotlin.time.Duration.Companion.milliseconds - -private const val TAG = "ImagePage" -private const val DEBUG = false - -@SuppressLint("ConfigurationScreenWidthHeight") -@OptIn(UnstableApi::class) -@kotlin.OptIn(ExperimentalCoilApi::class) -@Composable -fun SlideshowPage( - slideshow: Destination.Slideshow, - modifier: Modifier = Modifier, - viewModel: SlideshowViewModel = - hiltViewModel<SlideshowViewModel, SlideshowViewModel.Factory>( - creationCallback = { - it.create(slideshow) - }, - ), -) { - val context = LocalContext.current - val state by viewModel.state.collectAsState() - - when (val st = state.loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } - - LoadingState.Loading, - LoadingState.Pending, - -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - val loadingState = state.imageLoading - val imageFilter by viewModel.imageFilter.collectAsState() - val position = state.position - val pager = state.items -// val imageState by viewModel.image.observeAsState() - - var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } - val isZoomed = zoomFactor * 100 > 102 - var rotation by rememberSaveable { mutableFloatStateOf(0f) } - var showOverlay by rememberSaveable { mutableStateOf(false) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } - var panX by rememberSaveable { mutableFloatStateOf(0f) } - var panY by rememberSaveable { mutableFloatStateOf(0f) } - - val slideshowControls = - object : SlideshowControls { - override fun startSlideshow() { - showOverlay = false - viewModel.startSlideshow() - } - - override fun stopSlideshow() { - viewModel.stopSlideshow() - } - } - - val rotateAnimation: Float by animateFloatAsState( - targetValue = rotation, - label = "image_rotation", - ) - val zoomAnimation: Float by animateFloatAsState( - targetValue = zoomFactor, - label = "image_zoom", - ) - val panXAnimation: Float by animateFloatAsState( - targetValue = panX, - label = "image_panX", - ) - val panYAnimation: Float by animateFloatAsState( - targetValue = panY, - label = "image_panY", - ) - - val slideshowState by viewModel.state.collectAsState() - val slideshowActive by viewModel.slideshowActive.collectAsState(false) - - val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - - val density = LocalDensity.current - val screenHeight = LocalWindowInfo.current.containerSize.height - val screenWidth = LocalWindowInfo.current.containerSize.width - - val maxPanX = screenWidth * .75f - val maxPanY = screenHeight * .75f - - fun reset(resetRotate: Boolean) { - zoomFactor = 1f - panX = 0f - panY = 0f - if (resetRotate) rotation = 0f - } - - fun pan( - xFactor: Int, - yFactor: Int, - ) { - if (xFactor != 0) { - panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) - } - if (yFactor != 0) { - panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) - } - } - - fun zoom(factor: Float) { - if (factor < 0) { - val diffFactor = factor / (zoomFactor - 1f) - // zooming out - val panXDiff = abs(panX * diffFactor) - val panYDiff = abs(panY * diffFactor) - if (DEBUG) { - Timber.d( - "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", - ) - } - if (panX > 0f) { - panX -= panXDiff - } else if (panX < 0f) { - panX += panXDiff - } - if (panY > 0f) { - panY -= panYDiff - } else if (panY < 0f) { - panY += panYDiff - } - } - zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) - if (!isZoomed) { - // Always reset if not zoomed - panX = 0f - panY = 0f - } - } - - val player = viewModel.player - val presentationState = rememberPresentationState(player) - LaunchedEffect(slideshowActive) { - player.repeatMode = - if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - } - - var longPressing by remember { mutableStateOf(false) } - - Box( - modifier = - modifier - .background(Color.Black) - .focusRequester(focusRequester) - .focusable() - .onKeyEvent { - val isOverlayShowing = showOverlay || showFilterDialog - var result = false - if (!isOverlayShowing) { - if (longPressing && it.type == KeyEventType.KeyUp) { - // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image - longPressing = false - return@onKeyEvent true - } - longPressing = - it.nativeKeyEvent.isLongPress || - it.nativeKeyEvent.repeatCount > 0 - if (longPressing) { - when (it.key) { - Key.DirectionUp -> zoom(.05f) - Key.DirectionDown -> zoom(-.05f) - - // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan - // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } - // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } - } - return@onKeyEvent true - } - } - if (it.type != KeyEventType.KeyUp) { - result = false - } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { - // Image is zoomed in - when (it.key) { - Key.DirectionLeft -> pan(30, 0) - Key.DirectionRight -> pan(-30, 0) - Key.DirectionUp -> pan(0, 30) - Key.DirectionDown -> pan(0, -30) - } - result = true - } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { - reset(false) - result = true - } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { - when (it.key) { - Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { - if (!viewModel.previousImage()) { - Toast - .makeText( - context, - R.string.slideshow_at_beginning, - Toast.LENGTH_SHORT, - ).show() - } - } - - Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { - if (!viewModel.nextImage()) { - Toast - .makeText( - context, - R.string.no_more_images, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } else if (isOverlayShowing && it.key == Key.Back) { - showOverlay = false - viewModel.unpauseSlideshow() - result = true - } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { - showOverlay = true - viewModel.pauseSlideshow() - result = true - } - if (result) { - // Handled the key, so reset the slideshow timer - viewModel.pulseSlideshow() - } - result - }, - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null, modifier) - } - - ImageLoadingState.Loading -> { - LoadingPage(modifier, false) - } - - is ImageLoadingState.Success -> { - val imageState = st.image - LaunchedEffect(imageState) { - reset(true) - } - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() - } - } - val contentScale = ContentScale.Fit - val scaledModifier = - Modifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = - TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .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, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, - ) - } - } - } - AnimatedVisibility( - showOverlay, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier.align(Alignment.BottomStart), - ) { - when (val st = loadingState) { - ImageLoadingState.Error -> {} - - ImageLoadingState.Loading -> {} - - is ImageLoadingState.Success -> { - val imageState = st.image - ImageOverlay( - modifier = - Modifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) - } - } - } - AnimatedVisibility(showFilterDialog) { - ImageFilterDialog( - filter = imageFilter, - showVideoOptions = false, - showSaveGalleryButton = true, - onChange = viewModel::updateImageFilter, - onClickSave = viewModel::saveImageFilter, - onClickSaveGallery = viewModel::saveGalleryFilter, - onDismissRequest = { - showFilterDialog = false - viewModel.unpauseSlideshow() - viewModel.pulseSlideshow() - }, - ) - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt deleted file mode 100644 index 8172b049..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ /dev/null @@ -1,513 +0,0 @@ -package com.github.damontecres.wholphin.ui.slideshow - -import android.content.Context -import android.widget.Toast -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 -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.PlaybackEffect -import com.github.damontecres.wholphin.data.model.VideoFilter -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.services.PlayerFactory -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.StreamChoiceReason -import com.github.damontecres.wholphin.services.StreamChoiceResult -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.ui.PhotoItemFields -import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.showToast -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.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.libraryApi -import org.jellyfin.sdk.api.client.extensions.videosApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import java.util.UUID -import kotlin.properties.Delegates - -@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class) -class SlideshowViewModel - @AssistedInject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val playerFactory: PlayerFactory, - private val playbackEffectDao: PlaybackEffectDao, - private val serverRepository: ServerRepository, - private val imageUrlService: ImageUrlService, - private val userPreferencesService: UserPreferencesService, - private val screensaverService: ScreensaverService, - @Assisted val slideshowSettings: Destination.Slideshow, - ) : ViewModel(), - Player.Listener { - @AssistedFactory - interface Factory { - fun create(slideshow: Destination.Slideshow): SlideshowViewModel - } - - lateinit var player: Player - private set - - private var saveFilters = true - - /** - * Whether slideshow mode is on or off - */ - private val _state = MutableStateFlow(SlideshowState()) - val state: StateFlow<SlideshowState> = _state - - /** - * Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused - */ - val slideshowActive = state.map { it.enabled && !it.paused } - - var slideshowDelay by Delegates.notNull<Long>() - - private val _imageFilter = MutableStateFlow(VideoFilter()) - val imageFilter: StateFlow<VideoFilter> = _imageFilter - - private var albumImageFilter = VideoFilter() - - init { - addCloseable { - screensaverService.keepScreenOn(false) - if (this@SlideshowViewModel::player.isInitialized) { - player.removeListener(this@SlideshowViewModel) - player.release() - } - } - viewModelScope.launchIO { - try { - val appPreferences = userPreferencesService.getCurrent().appPreferences - val playerCreation = - playerFactory.createVideoPlayer( - backend = PlayerBackend.EXO_PLAYER, - appPreferences, - ) - player = playerCreation.player - player.addListener(this@SlideshowViewModel) - - val photoPrefs = appPreferences.photoPreferences - slideshowDelay = - photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } - ?: AppPreference.SlideshowDuration.defaultValue - val includeItemTypes = - if (photoPrefs.slideshowPlayVideos) { - listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) - } else { - listOf(BaseItemKind.PHOTO) - } - val request = - slideshowSettings.filter.filter.applyTo( - GetItemsRequest( - parentId = slideshowSettings.parentId, - includeItemTypes = includeItemTypes, - fields = PhotoItemFields, - recursive = slideshowSettings.recursive, - sortBy = listOf(slideshowSettings.sortAndDirection.sort), - sortOrder = listOf(slideshowSettings.sortAndDirection.direction), - ), - ) - serverRepository.currentUser?.let { user -> - - val filter = - slideshowSettings.parentId?.let { - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter - } - if (filter != null) { - Timber.v("Got filter for album %s", slideshowSettings.parentId) - albumImageFilter = filter - } - } - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - .init(slideshowSettings.index) - _state.update { - it.copy( - loading = LoadingState.Success, - items = pager, - ) - } - updatePosition(slideshowSettings.index)?.join() - if (slideshowSettings.startSlideshow) onMain { startSlideshow() } - } catch (ex: Exception) { - Timber.e(ex, "Error") - _state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun nextImage(): Boolean { - val size = state.value.items.size - val newPosition = state.value.position + 1 - return if (newPosition < size) { - updatePosition(newPosition, true) - true - } else { - false - } - } - - fun previousImage(): Boolean { - val newPosition = state.value.position - 1 - return if (newPosition >= 0) { - updatePosition(newPosition, false) - true - } else { - false - } - } - - /** - * 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 { - val image = - if (position in pager.indices) pager.getBlocking(position) else null - Timber.v("Got image for $position: ${image != null}") - if (image != null) { - _state.update { it.copy(position = position) } - - val url = - if (image.data.mediaType == MediaType.VIDEO) { - // TODO this assumes direct play - api.videosApi.getVideoStreamUrl( - itemId = image.id, - ) - } else { - api.libraryApi.getDownloadUrl(image.id) - } - val chosenStreams = - if (image.data.mediaType == MediaType.VIDEO) { - image.data.mediaSources?.firstOrNull()?.let { source -> - val video = - source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } - val audio = - source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } - ChosenStreams( - itemPlayback = null, - audioStc = emptyList(), - subtitleStc = emptyList(), - itemId = image.id, - source = source, - videoStream = video, - audioStream = - StreamChoiceResult( - audio, - StreamChoiceReason.Unknown, - ), - subtitleStream = - StreamChoiceResult( - null, - StreamChoiceReason.Unknown, - ), - subtitlesDisabled = false, - ) - } - } else { - null - } - - val imageState = - ImageState( - image, - url, - imageUrlService.getItemImageUrl(image, ImageType.THUMB), - chosenStreams, - ) - // reset image filter - updateImageFilter(albumImageFilter) - if (saveFilters) { - viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> - val vf = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - image.id, - BaseItemKind.PHOTO, - ) - if (vf != null && vf.videoFilter.hasImageFilter()) { - Timber.d( - "Loaded VideoFilter for image ${image.id}", - ) - updateImageFilter(vf.videoFilter) - } - _state.update { - it.copy( - image = imageState, - imageLoading = ImageLoadingState.Success(imageState), - ) - } - } - } - } else { - _state.update { - it.copy( - image = imageState, - imageLoading = ImageLoadingState.Success(imageState), - ) - } - } - } else { - _state.update { - it.copy( - imageLoading = ImageLoadingState.Error, - ) - } - } - } catch (ex: Exception) { - Timber.e(ex) - _state.update { - it.copy( - imageLoading = ImageLoadingState.Error, - ) - } - } - 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") - } - } - } - - private var slideshowJob: Job? = null - - fun startSlideshow() { - screensaverService.keepScreenOn(true) - _state.update { - it.copy(enabled = true, paused = false) - } - if (state.value.image - ?.image - ?.data - ?.mediaType != MediaType.VIDEO - ) { - pulseSlideshow() - } - } - - fun stopSlideshow() { - screensaverService.keepScreenOn(false) - slideshowJob?.cancel() - _state.update { - it.copy(enabled = false, paused = false) - } - } - - fun pauseSlideshow() { - Timber.v("pauseSlideshow") - _state.update { - if (it.enabled) { - slideshowJob?.cancel() - it.copy(paused = true) - } else { - it - } - } - } - - fun unpauseSlideshow() { - Timber.v("unpauseSlideshow") - _state.update { - if (it.enabled) { - it.copy(paused = false) - } else { - it - } - } - } - - fun pulseSlideshow() = pulseSlideshow(slideshowDelay) - - fun pulseSlideshow(milliseconds: Long) { -// Timber.v("pulseSlideshow $milliseconds") - slideshowJob?.cancel() - slideshowJob = - viewModelScope - .launchIO { - delay(milliseconds) -// Timber.v("pulseSlideshow after delay") - if (slideshowActive.first()) { - // Next image or loop to beginning - if (!nextImage()) updatePosition(0) - } - }.apply { - invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() } - } - } - - fun updateImageFilter(newFilter: VideoFilter) { - viewModelScope.launchIO { - _imageFilter.update { newFilter } - } - } - - fun saveImageFilter() { - state.value.image?.let { - viewModelScope.launchIO { - val vf = _imageFilter.value - if (vf != null) { - serverRepository.currentUser?.let { user -> - playbackEffectDao - .insert( - PlaybackEffect( - user.rowId, - it.image.id, - BaseItemKind.PHOTO, - vf, - ), - ) - Timber.d("Saved VideoFilter for image %s", it.image.id) - withContext(WholphinDispatchers.Main) { - showToast( - context, - "Saved", - Toast.LENGTH_SHORT, - ) - } - } - } - } - } - } - - fun saveGalleryFilter() { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - val vf = _imageFilter.value - if (slideshowSettings.parentId != null) { - albumImageFilter = vf - serverRepository.currentUser?.let { user -> - playbackEffectDao - .insert( - PlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - vf, - ), - ) - Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) - withContext(WholphinDispatchers.Main) { - showToast( - context, - "Saved", - Toast.LENGTH_SHORT, - ) - } - } - } - } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - pulseSlideshow(slideshowDelay) - } - } - } - -interface SlideshowControls { - fun startSlideshow() - - fun stopSlideshow() -} - -sealed class ImageLoadingState { - data object Loading : ImageLoadingState() - - data object Error : ImageLoadingState() - - data class Success( - val image: ImageState, - ) : ImageLoadingState() -} - -@Stable -data class ImageState( - val image: BaseItem, - val url: String, - val thumbnailUrl: String?, - val chosenStreams: ChosenStreams?, -) { - val id: UUID get() = image.id -} - -data class SlideshowState( - val items: List<BaseItem?> = emptyList(), - val position: Int = 0, - val image: ImageState? = null, - val loading: LoadingState = LoadingState.Pending, - val imageLoading: ImageLoadingState = ImageLoadingState.Loading, - val enabled: Boolean = false, - val paused: Boolean = false, -) 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 8ae95b25..112d8817 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,36 +7,30 @@ 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 } -fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = - when (appThemeColors) { - AppThemeColors.PURPLE -> PurpleThemeColors - AppThemeColors.BLUE -> BlueThemeColors - AppThemeColors.GREEN -> GreenThemeColors - AppThemeColors.ORANGE -> OrangeThemeColors - AppThemeColors.OLED_BLACK -> OledThemeColors - AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors - AppThemeColors.RED -> RedThemeColors - AppThemeColors.BROWN -> BrownThemeColors - AppThemeColors.UNRECOGNIZED -> PurpleThemeColors - } - @Composable fun WholphinTheme( darkTheme: Boolean = true, appThemeColors: AppThemeColors = AppThemeColors.PURPLE, content: @Composable () -> Unit, ) { - val themeColors = getThemeColors(appThemeColors) + val themeColors = + when (appThemeColors) { + AppThemeColors.PURPLE -> PurpleThemeColors + AppThemeColors.BLUE -> BlueThemeColors + AppThemeColors.GREEN -> GreenThemeColors + AppThemeColors.ORANGE -> OrangeThemeColors + AppThemeColors.OLED_BLACK -> OledThemeColors + AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors + AppThemeColors.UNRECOGNIZED -> PurpleThemeColors + } val colorScheme = when { 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 f5c83ce1..1e912448 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,7 +10,6 @@ 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 @@ -20,10 +19,8 @@ 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 import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawerScope @@ -33,48 +30,39 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem -import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackButton +import com.github.damontecres.wholphin.ui.playback.PlaybackButton import com.github.damontecres.wholphin.ui.preferences.SliderPreference import com.github.damontecres.wholphin.ui.preferences.SwitchPreference import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=3000dp", + device = "spec:width=1200dp,height=2000dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @Composable private fun ThemePreview() { - val themes = AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED } Column { LazyVerticalGrid( - columns = GridCells.Fixed(3), + columns = GridCells.Fixed(2), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - items(themes) { + items(AppThemeColors.entries.filterNot { it == AppThemeColors.UNRECOGNIZED }) { 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() } @@ -84,37 +72,33 @@ 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, modifier = Modifier.fillMaxWidth(), ) { WatchedIcon() - // TODO -// BannerCard( -// name = "Card", -// imageUrl = null, -// onClick = { }, -// onLongClick = {}, -// playPercent = .5, -// cardHeight = 64.dp, -// ) -// BannerCard( -// name = "Card", -// imageUrl = null, -// onClick = { }, -// onLongClick = {}, -// playPercent = .5, -// cardHeight = 64.dp, -// interactionSource = source, -// ) + BannerCard( + name = "Card", + imageUrl = null, + onClick = { }, + onLongClick = {}, + playPercent = .5, + cardHeight = 64.dp, + ) + BannerCard( + name = "Card", + imageUrl = null, + onClick = { }, + onLongClick = {}, + playPercent = .5, + cardHeight = 64.dp, + interactionSource = source, + ) SeasonCard( title = "Card", subtitle = "2025", @@ -124,7 +108,6 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 50.0, - numberOfVersions = 2, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -141,7 +124,6 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 0.0, - numberOfVersions = 0, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -150,7 +132,6 @@ private fun ThemeExample(theme: AppThemeColors) { aspectRatio = AspectRatios.SQUARE, ) } - SectionTitle("Controls") Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SliderPreference( preference = AppPreference.AutoPlayNextDelay, @@ -208,7 +189,6 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } - SectionTitle("Nav drawer") val navScope = NavScope(true) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -258,7 +238,6 @@ private fun ThemeExample(theme: AppThemeColors) { ) } } - SectionTitle("Playback controls") Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), @@ -290,25 +269,6 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } - SectionTitle("List items") - Column( - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - ListItem( - selected = false, - enabled = true, - headlineContent = { Text("Headline content") }, - supportingContent = { Text("Support content") }, - onClick = {}, - ) - ListItem( - selected = true, - enabled = true, - headlineContent = { Text("Headline content") }, - supportingContent = { Text("Support content") }, - onClick = {}, - ) - } } } } 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 deleted file mode 100644 index 04c050fd..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/BrownThemeColors.kt +++ /dev/null @@ -1,204 +0,0 @@ -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/PurpleThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt index 68583c4c..813aaf79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt @@ -41,7 +41,7 @@ val PurpleThemeColors = val secondaryDark = Color(0xFFD2BCFF) val onSecondaryDark = Color(0xFF3B167D) val secondaryContainerDark = Color(0xFF48415B) - val onSecondaryContainerDark = Color(0xFFDFD3F8) + val onSecondaryContainerDark = Color(0xFFC2A6FF) val tertiaryDark = Color(0xFFA071F8) val onTertiaryDark = Color(0xFF5E0052) val tertiaryContainerDark = Color(0xFFB800A3) 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 deleted file mode 100644 index ecdcbdf4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/RedThemeColors.kt +++ /dev/null @@ -1,204 +0,0 @@ -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/DelayedNestedScrollConnection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/DelayedNestedScrollConnection.kt deleted file mode 100644 index 5092c8f1..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/DelayedNestedScrollConnection.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import androidx.annotation.FloatRange -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource - -class DelayedNestedScrollConnection( - @param:FloatRange(0.0, 1.0) - private val xDelay: Float = 0f, - @param:FloatRange(0.0, 1.0) - private val yDelay: Float = .6f, -) : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset = - if (source == NestedScrollSource.UserInput) { - Offset(x = available.x * xDelay, y = available.y * yDelay) - } else { - Offset.Zero - } -} - -/** - * Add a delay to the scroll speed via user interaction - * - * In some cases, slowing down scrolling makes it feel smoother and less jerky visually - * - * ```kotlin - * val scrollState = rememberScrollState() - * val scrollConnection = rememberDelayedNestedScroll(.5f, .5f) - * Column( - * modifier = - * Modifier - * .nestedScroll(scrollConnection) - * .verticalScroll(scrollState) - * ){ content() } - * ``` - * - * @param xDelay how much to delay left-right scrolling - * @param yDelay how much to delay up-down scrolling - * - * @see rememberDelayedNestedScroll - * @see androidx.compose.ui.input.nestedscroll.nestedScroll - */ -@Composable -fun rememberDelayedNestedScroll( - @FloatRange(0.0, 1.0) - xDelay: Float = 0f, - @FloatRange(0.0, 1.0) - yDelay: Float = .33f, -) = remember(xDelay, yDelay) { DelayedNestedScrollConnection(xDelay, yDelay) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt deleted file mode 100644 index 30bcac68..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/InterfaceCustomization.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import androidx.compose.runtime.staticCompositionLocalOf -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.DisplayToggle -import java.util.EnumSet - -/** - * Represents various UI preferences made available via [LocalInterfaceCustomization] - */ -data class InterfaceCustomization( - val enabledDisplayToggles: EnumSet<DisplayToggle>, -) { - constructor(prefs: AppPreferences) : this( - prefs.interfacePreferences.displayTogglesList.let { - if (it.isEmpty()) { - EnumSet.noneOf(DisplayToggle::class.java) - } else { - EnumSet.copyOf(it) - } - }, - ) -} - -val LocalInterfaceCustomization = - staticCompositionLocalOf<InterfaceCustomization> { - InterfaceCustomization(EnumSet.allOf(DisplayToggle::class.java)) - } 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 deleted file mode 100644 index 2639a919..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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 -import java.time.LocalDateTime - -val LocalClock = compositionLocalOf<Clock> { Clock() } - -/** - * Represents the current time - */ -data class Clock( - /** - * The current [LocalDateTime] - */ - val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()), - /** - * The current time formatted as a string; populated by [ProvideLocalClock] via [formatTime]. - */ - val timeString: MutableState<String> = mutableStateOf(""), -) - -@Composable -fun ProvideLocalClock(content: @Composable () -> Unit) { - val context = LocalContext.current - val clock = remember { Clock() } - LaunchedEffect(context) { - withContext(WholphinDispatchers.Default) { - while (isActive) { - val now = LocalDateTime.now() - val time = formatTime(context, now) - clock.now.value = now - clock.timeString.value = time - delay(2_000) - } - } - } - CompositionLocalProvider(LocalClock provides clock, content) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt deleted file mode 100644 index 31f4f6fa..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -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.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.util.BlockingList -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import kotlin.random.Random - -/** - * Navigate to a random item in the given list - */ -suspend fun goToRandom( - navigateTo: (Destination) -> Unit, - parentId: String, - filter: GetItemsFilter, - sortAndDirection: SortAndDirection, - recursive: Boolean, - list: BlockingList<BaseItem?>, -) { - val randomIndex = Random.nextInt(list.size) - val random = list.getBlocking(randomIndex) - - Timber.d("Got random item #%s: %s", randomIndex, random?.id) - if (random != null) { - val destination = - if (random.type == BaseItemKind.PHOTO) { - Destination.Slideshow( - parentId = parentId.toUUIDOrNull(), - index = randomIndex, - filter = - CollectionFolderFilter( - filter = filter, - ), - sortAndDirection = sortAndDirection, - recursive = recursive, - startSlideshow = false, - ) - } else { - random.destination() - } - navigateTo(destination) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt deleted file mode 100644 index 73f22de6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import androidx.compose.foundation.lazy.LazyColumn - -/** - * Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels - * - * Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too - * - * Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides - * - * Example: - * ```kotlin - * val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current - * CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) { - * LazyColumn{ - * items(list){ - * CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { - * // Content - * } - * } - * } - * } - * ``` - */ -class ScrollToTopBringIntoViewSpec( - val spaceAbovePx: Float = 100f, -) : BringIntoViewSpec { - override fun calculateScrollDistance( - offset: Float, - size: Float, - containerSize: Float, - ): Float { -// Timber.v( -// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s", -// offset, -// size, -// containerSize, -// ) - return offset - spaceAbovePx - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt deleted file mode 100644 index 4f1a592d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import android.content.res.Resources -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.util.languageName -import com.github.damontecres.wholphin.util.profile.Codec -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType - -/** - * Collection of utility functions for formatting the display of media streams - */ -object StreamFormatting { - fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" - - // Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 - fun resolutionString( - width: Int, - height: Int, - interlaced: Boolean, - ): String = - if (height > width) { - // Vertical video - resolutionString(height, width, interlaced) - } else { - when { - width > 5120 || height > 4320 -> "8K" - width > 2560 || height > 1440 -> "4K" - width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) - width > 1280 || height > 962 -> "1080" + interlaced(interlaced) - width > 1024 || height > 576 -> "720" + interlaced(interlaced) - width > 960 || height > 544 -> "576" + interlaced(interlaced) - width > 845 || height > 480 -> "540" + interlaced(interlaced) - width > 720 || height > 404 -> "480" + interlaced(interlaced) - width > 682 || height > 384 -> "404" + interlaced(interlaced) - width > 640 || height > 360 -> "384" + interlaced(interlaced) - width > 426 || height > 240 -> "360" + interlaced(interlaced) - width > 256 || height > 144 -> "240" + interlaced(interlaced) - else -> height.toString() + interlaced(interlaced) - } - } - - fun formatVideoRange( - resources: Resources, - videoRange: VideoRange?, - type: VideoRangeType?, - doviTitle: String?, - ): String? = - when (videoRange) { - VideoRange.UNKNOWN, - VideoRange.SDR, null, - -> { - null - } - - VideoRange.HDR -> { - if (doviTitle.isNotNullOrBlank()) { - resources.getString(R.string.dolby_vision) - } else { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null - - VideoRangeType.HDR10 -> "HDR10" - - VideoRangeType.HDR10_PLUS -> "HDR10+" - - VideoRangeType.HLG -> "HLG" - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> resources.getString(R.string.dolby_vision) - } - } - } - } - - fun formatAudioCodec( - resources: Resources, - codec: String?, - profile: String?, - ): String? = - when { - profile?.contains("Dolby Atmos", true) == true -> { - resources.getString(R.string.dolby_atmos) - } - - profile?.contains("DTS", true) == true -> { - when { - profile.contains("X", true) -> resources.getString(R.string.dts_x) - profile.contains("MA", true) -> resources.getString(R.string.dts_hd_ma) - profile.contains("HD", true) -> resources.getString(R.string.dts_hd) - else -> resources.getString(R.string.dts) - } - } - - else -> { - when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> resources.getString(R.string.truehd) - - Codec.Audio.AC3 -> resources.getString(R.string.dolby_digital) - - Codec.Audio.EAC3 -> resources.getString(R.string.dolby_digital_plus) - - Codec.Audio.DCA -> resources.getString(R.string.dts) - - Codec.Audio.OGG, - Codec.Audio.OPUS, - Codec.Audio.VORBIS, - -> codec.replaceFirstChar { it.uppercase() } - - null -> null - - else -> codec.uppercase() - } - } - } - - fun formatSubtitleCodec(codec: String?): String? = - when (codec?.lowercase()) { - Codec.Subtitle.DVBSUB -> "DVB" - Codec.Subtitle.DVDSUB -> "DVD" - Codec.Subtitle.PGSSUB -> "PGS" - Codec.Subtitle.SUBRIP -> "SRT" - null -> null - else -> codec.uppercase() - } - - fun String?.concatWithSpace(str: String?): String? = - when { - this != null && str != null -> "$this $str" - this == null -> str - else -> this - } - - fun mediaStreamDisplayTitle( - resources: Resources, - stream: MediaStream, - includeFlags: Boolean, - ): String { - val name = - buildList { - add(languageName(stream.language)) - if (stream.type == MediaStreamType.AUDIO) { - add(formatAudioCodec(resources, stream.codec, stream.profile)) - add(stream.channelLayout) - } else if (stream.type == MediaStreamType.SUBTITLE) { - "SDH".takeIf { stream.isHearingImpaired }?.let(::add) - add(formatSubtitleCodec(stream.codec)) - } - }.joinToString(" ") - if (includeFlags) { - val flags = - buildList { - if (stream.isDefault) add(stream.localizedDefault) - if (stream.isForced) add(stream.localizedForced) - if (stream.isExternal) add(stream.localizedExternal) - }.joinToString(", ") - if (flags.isNotEmpty()) { - return "$name ($flags)" - } - } - return name - } -} 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 deleted file mode 100644 index c63c4067..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StringProvider.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.ui.res.stringResource -import kotlinx.serialization.Serializable - -/** - * Flexible way to provide a string for the UI - */ -@Serializable -sealed interface StringProvider { - @Composable - @NonRestartableComposable - fun getString(): String -} - -/** - * Provides a string literal - */ -@Serializable -data class StringStringProvider( - val str: String, -) : StringProvider { - @Composable - @NonRestartableComposable - override fun getString(): String = str -} - -/** - * Provide an empty string literal - */ -@Serializable -data object EmptyStringProvider : StringProvider { - @Composable - @NonRestartableComposable - override fun getString(): String = "" -} - -/** - * Provides a string resource using [stringResource] - */ -@Serializable -data class ResStringProvider( - @param:StringRes val stringResId: Int, -) : StringProvider { - @Composable - @NonRestartableComposable - override fun getString() = stringResource(stringResId) -} - -/** - * Provides a string resource with format arguments using [stringResource] - */ -@Serializable -data class ResArgStringProvider( - @param:StringRes val stringResId: Int, - val args: Array<String>, -) : StringProvider { - constructor(stringResId: Int, arg: String) : this(stringResId, arrayOf(arg)) - - @Composable - @NonRestartableComposable - override fun getString() = stringResource(stringResId, *args) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ResArgStringProvider) return false - - if (stringResId != other.stringResId) return false - if (!args.contentEquals(other.args)) return false - - return true - } - - override fun hashCode(): Int { - var result = stringResId - result = 31 * result + args.contentHashCode() - return result - } -} - -/** - * Provides a string resource with another [StringProvider] format argument using [stringResource] - */ -@Serializable -data class ResProviderStringProvider( - @param:StringRes val stringResId: Int, - val argProvider: StringProvider, -) : StringProvider { - @Composable - @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 2f98fccc..3156bb3d 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 @@ -1,120 +1,197 @@ package com.github.damontecres.wholphin.util +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.data.model.BaseItem 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 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 import org.jellyfin.sdk.api.client.extensions.personsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi -import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi 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.GetAlbumArtistsRequest -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 -import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest -import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID +import java.util.function.Predicate /** - * A [RequestPager] for Jellyfin server queries + * Handles paging for an API request. You must call [init] prior to use. + * + * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. + * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. + * + * Finally, items are cached allow for backward and forward scrolling. */ class ApiRequestPager<T>( val api: ApiClient, val request: T, val requestHandler: RequestHandler<T>, - scope: CoroutineScope, - pageSize: Int = DEFAULT_PAGE_SIZE, + private val scope: CoroutineScope, + private val pageSize: Int = DEFAULT_PAGE_SIZE, cacheSize: Long = 8, private val useSeriesForPrimary: Boolean = false, -) : RequestPager<BaseItem>(scope, pageSize, cacheSize) { - override suspend fun init(initialPosition: Int): ApiRequestPager<T> = super.init(initialPosition) as ApiRequestPager<T> +) : AbstractList<BaseItem?>(), + BlockingList<BaseItem?> { + private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf())) + private var totalCount by mutableIntStateOf(-1) + private val mutex = Mutex() + private val cachedPages = + CacheBuilder + .newBuilder() + .maximumSize(cacheSize) + .build<Int, MutableList<BaseItem>>() - override suspend fun fetchPage( - pageNumber: Int, - includeTotalCount: Boolean, - ): QueryResult<BaseItem> { - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - includeTotalCount, - ) - val result = requestHandler.execute(api, newRequest).content - val data = mutableListOf<BaseItem>() - result.items.forEach { data.add(BaseItem(it, useSeriesForPrimary)) } - return QueryResult(data, result.totalRecordCount) + suspend fun init(): ApiRequestPager<T> { + if (totalCount < 0) { + val newRequest = requestHandler.prepare(request, 0, 1, true) + val result = requestHandler.execute(api, newRequest).content + totalCount = result.totalRecordCount + } + return this } + override operator fun get(index: Int): BaseItem? { + if (index in 0..<totalCount) { + val item = items[index] + if (item == null) { + fetchPage(index) + } + return item + } else { + throw IndexOutOfBoundsException("$index of $totalCount") + } + } + + override suspend fun getBlocking(index: Int): BaseItem? { + if (index in 0..<totalCount) { + val item = items[index] + if (item == null) { + fetchPage(index).join() + return items[index] + } + return item + } else { + throw IndexOutOfBoundsException("$index of $totalCount") + } + } + + override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): Int { + init() + for (i in 0 until totalCount) { + val currentItem = getBlocking(i) + if (currentItem != null && predicate.test(currentItem)) { + return i + } + } + return -1 + } + + override val size: Int + get() = totalCount + + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + Dispatchers.IO) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + false, + ) + val result = requestHandler.execute(api, newRequest).content + val data = mutableListOf<BaseItem>() + result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) } + cachedPages.put(pageNumber, data) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + suspend fun refreshItem( position: Int, itemId: UUID, ) { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize mutex.withLock { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from( - it, - api, - useSeriesForPrimary, - ) - } - val pageNumber = position / pageSize - val index = position - pageNumber * pageSize val page = cachedPages.getIfPresent(pageNumber) if (page != null && index in page.indices) { page[index] = item cachedPages.put(pageNumber, page) - items = ItemList(size, pageSize, cachedPages.asMap()) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) } } } - /** - * Dumps the cache for all the pages at or after the given position and fetches a new page - */ - suspend fun refreshPagesAfter(position: Int) { - val pageNumber = position / pageSize - cachedPages.asMap().apply { - keys.forEach { pageKey -> - if (pageKey >= pageNumber) { - if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) - remove(pageKey) + companion object { + private const val DEBUG = false + } + + class ItemList<T>( + val size: Int, + val pageSize: Int, + val pages: Map<Int, List<T>>, + ) { + operator fun get(position: Int): T? { + val page = position / pageSize + val data = pages[page] + if (data != null) { + val index = position % pageSize + if (index in data.indices) { + return data[index] + } else { + // This can happen when items are removed while scrolling + Timber.w( + "Index $index not in data: position=$position, data.size=${data.size}", + ) + return null } + } else { + return null } } - fetchPageBlocking(position, true) } } /** - * Specifies how a [RequestPager] should prepare and execute API calls + * Specifies how the [ApiRequestPager] should prepare and execute API calls */ -@Serializable -sealed interface RequestHandler<T> { +interface RequestHandler<T> { /** * Prepare the given request with the specified parameters (eg which page to fetch) */ @@ -132,311 +209,180 @@ sealed 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 +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, + ) + + override suspend fun execute( + api: ApiClient, + request: GetItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) } -} -@Serializable -object GetItemsRequestHandler : RequestHandler<GetItemsRequest> { - override fun prepare( - request: GetItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +val GetEpisodesRequestHandler = + object : 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: GetItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getItems(request) -} + override suspend fun execute( + api: ApiClient, + request: GetEpisodesRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) + } -@Serializable -object GetEpisodesRequestHandler : RequestHandler<GetEpisodesRequest> { - override fun prepare( - request: GetEpisodesRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetEpisodesRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +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, + ) - override suspend fun execute( - api: ApiClient, - request: GetEpisodesRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getEpisodes(request) -} + override suspend fun execute( + api: ApiClient, + request: GetResumeItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) + } -@Serializable -object GetResumeItemsRequestHandler : RequestHandler<GetResumeItemsRequest> { - override fun prepare( - request: GetResumeItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetResumeItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +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, + ) - override suspend fun execute( - api: ApiClient, - request: GetResumeItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(request) -} + override suspend fun execute( + api: ApiClient, + request: GetNextUpRequest, + ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) + } -@Serializable -object GetNextUpRequestHandler : RequestHandler<GetNextUpRequest> { - override fun prepare( - request: GetNextUpRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetNextUpRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +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, + ) - override suspend fun execute( - api: ApiClient, - request: GetNextUpRequest, - ): Response<BaseItemDtoQueryResult> = api.tvShowsApi.getNextUp(request) -} + override suspend fun execute( + api: ApiClient, + request: GetSuggestionsRequest, + ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) + } -@Serializable -object GetSuggestionsRequestHandler : RequestHandler<GetSuggestionsRequest> { - override fun prepare( - request: GetSuggestionsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetSuggestionsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +val GetPlaylistItemsRequestHandler = + object : 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: GetSuggestionsRequest, - ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) -} + override suspend fun execute( + api: ApiClient, + request: GetPlaylistItemsRequest, + ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) + } -@Serializable -object GetPlaylistItemsRequestHandler : RequestHandler<GetPlaylistItemsRequest> { - override fun prepare( - request: GetPlaylistItemsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetPlaylistItemsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) +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, + ) - override suspend fun execute( - api: ApiClient, - request: GetPlaylistItemsRequest, - ): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request) -} + override suspend fun execute( + api: ApiClient, + request: GetGenresRequest, + ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) + } -@Serializable -object GetGenresRequestHandler : RequestHandler<GetGenresRequest> { - override fun prepare( - request: GetGenresRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetGenresRequest = - request.copy( - startIndex = startIndex, - limit = limit, - enableTotalRecordCount = enableTotalRecordCount, - ) +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: GetGenresRequest, - ): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request) -} + override suspend fun execute( + api: ApiClient, + request: GetProgramsDto, + ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) + } -@Serializable -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( +val GetPersonsHandler = + object : 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)) -} - -@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) -} - -@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) -} - -@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) -} - -@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)) -} - -@Serializable -object GetAlbumArtistsHandler : RequestHandler<GetAlbumArtistsRequest> { - override fun prepare( - request: GetAlbumArtistsRequest, - startIndex: Int, - limit: Int, - enableTotalRecordCount: Boolean, - ): GetAlbumArtistsRequest = - request.copy( - startIndex = startIndex, - limit = limit, - ) - - override suspend fun execute( - api: ApiClient, - request: GetAlbumArtistsRequest, - ): Response<BaseItemDtoQueryResult> = api.artistsApi.getAlbumArtists(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(GetArtistsRequest::class) - subclass(GetAlbumArtistsRequest::class) - } + override suspend fun execute( + api: ApiClient, + request: GetPersonsRequest, + ): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt deleted file mode 100644 index cb1486f2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterFactory.kt +++ /dev/null @@ -1,128 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import android.media.MediaCodec -import androidx.annotation.OptIn -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.mediacodec.ForwardingMediaCodecAdapter -import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter -import timber.log.Timber -import java.nio.ByteBuffer - -/** - * Rewrites the bitstream of an access unit after the extractor and before it reaches the decoder. - * - * Implementations edit the buffer in place. Overwriting bytes leaves the access unit the size it - * was, removing bytes does not: a filter which drops part of an access unit has to move the bytes - * it keeps so that they stay contiguous from `offset`, and report the shorter length. The buffer's - * position and limit are not to be modified. - * - * The buffer is the decoder's input buffer, so it is direct: `hasArray()` is false and `array()` - * throws. Read and write it with the absolute `get(int)` and `put(int, Byte)`, or through a - * [ByteBuffer.duplicate] for bulk moves, never through a backing array. The absolute bulk - * `put(int, ByteBuffer, int, int)` is API 34 and `minSdk` is 23, so moving bytes means absolute - * puts or a duplicate. - */ -fun interface BitstreamFilter { - /** - * Filters the access unit in `[offset, offset + size)`. - * - * @return the new size of the access unit, which is never larger than [size]. Filters which - * only overwrite bytes return [size] unchanged. - */ - fun filter( - data: ByteBuffer, - offset: Int, - size: Int, - ): Int -} - -/** - * A [MediaCodecAdapter.Factory] which runs [filters] over the samples of Dolby Vision streams - * before they are queued to the decoder. - * - * The wrapper is only applied when a Dolby Vision decoder was actually selected, so playback which - * falls back to a regular HEVC/AVC decoder sees the bitstream as it came out of the extractor. - */ -class BitstreamFilteringCodecAdapterFactory( - private val delegate: MediaCodecAdapter.Factory, - private val filters: List<BitstreamFilter>, -) : MediaCodecAdapter.Factory { - override fun createAdapter(configuration: MediaCodecAdapter.Configuration): MediaCodecAdapter { - val adapter = delegate.createAdapter(configuration) - return if (filters.isNotEmpty() && configuration.codecInfo.mimeType == MimeTypes.VIDEO_DOLBY_VISION) { - Timber.i( - "Filtering the bitstream of Dolby Vision playback on %s with %s", - configuration.codecInfo.name, - filters.joinToString { it.javaClass.simpleName }, - ) - BitstreamFilteringCodecAdapter(adapter, filters) - } else { - adapter - } - } -} - -/** - * Runs each filter over the access unit in `[offset, offset + size)`, handing the next filter the - * length the previous one reported, and returns the length that is left to queue. A filter which - * reports a length it cannot have produced is ignored rather than trusted, since queueing the - * wrong length would feed the decoder a truncated or over long access unit. - */ -internal fun applyBitstreamFilters( - filters: List<BitstreamFilter>, - data: ByteBuffer, - offset: Int, - size: Int, -): Int { - var filteredSize = size - for (filter in filters) { - if (filteredSize <= 0) { - break - } - val newSize = filter.filter(data, offset, filteredSize) - if (newSize in 0..filteredSize) { - filteredSize = newSize - } else { - Timber.w( - "%s returned %d bytes for a %d byte access unit, keeping the access unit as it is", - filter.javaClass.simpleName, - newSize, - filteredSize, - ) - } - } - return filteredSize -} - -/** - * Filters each input buffer before it is queued. Encrypted samples go through - * [MediaCodecAdapter.queueSecureInputBuffer] and are forwarded untouched. - */ -internal class BitstreamFilteringCodecAdapter( - delegate: MediaCodecAdapter, - private val filters: List<BitstreamFilter>, -) : ForwardingMediaCodecAdapter(delegate) { - override fun queueInputBuffer( - index: Int, - offset: Int, - size: Int, - presentationTimeUs: Long, - flags: Int, - ) { - var filteredSize = size - if (size > 0 && (flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0) { - getInputBuffer(index)?.let { buffer -> - filteredSize = applyBitstreamFilters(filters, buffer, offset, size) - } - } - super.queueInputBuffer(index, offset, filteredSize, presentationTimeUs, flags) - } - - override fun release() { - Timber.d("Releasing bitstream filtering adapter, %s", filters.joinToString()) - super.release() - } -} 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 80272719..75ccc38e 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 @@ -1,44 +1,9 @@ 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 - */ interface BlockingList<T> : List<T> { - /** - * Get the specified index, possibly blocking until it is available - */ suspend fun getBlocking(index: Int): T - /** - * Get first index that matches the given predicate, possibly blocking while searching - */ 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] - */ - fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list) - } -} - -private class BlockingListWrapper<T>( - private val list: List<T>, -) : BlockingList<T>, - List<T> by list { - override suspend fun getBlocking(index: Int): T = get(index) - - override suspend fun indexOfBlocking(predicate: Predicate<T>): Int = indexOfFirst { predicate.test(it) } - - @Deprecated("Deprecated") - override fun <T> toArray(generator: IntFunction<Array<out T?>?>): Array<out T?> = super<List>.toArray(generator) } 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 f2078766..c0e93556 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -9,7 +9,6 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, - BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -32,8 +31,6 @@ val supportedCollectionTypes = CollectionType.LIVETV, CollectionType.MUSICVIDEOS, CollectionType.FOLDERS, - CollectionType.MUSIC, - CollectionType.PHOTOS, null, // Mixed ) @@ -42,20 +39,7 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, - BaseItemKind.MUSIC_VIDEO, 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 deleted file mode 100644 index 412130d7..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.damontecres.wholphin.util - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.ApiClientFactory -import org.jellyfin.sdk.api.client.HttpClientOptions -import org.jellyfin.sdk.api.client.HttpMethod -import org.jellyfin.sdk.api.client.RawResponse -import org.jellyfin.sdk.api.okhttp.OkHttpFactory -import org.jellyfin.sdk.api.sockets.SocketApi -import org.jellyfin.sdk.api.sockets.SocketConnection -import org.jellyfin.sdk.api.sockets.SocketConnectionFactory -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo -import kotlin.coroutines.CoroutineContext - -/** - * Wraps [ApiClient.request] with the given [CoroutineContext] - */ -class CoroutineContextApiClient( - private val client: ApiClient, - private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, -) : ApiClient() { - override val baseUrl: String? - get() = client.baseUrl - override val accessToken: String? - get() = client.accessToken - override val clientInfo: ClientInfo - get() = client.clientInfo - override val deviceInfo: DeviceInfo - get() = client.deviceInfo - override val httpClientOptions: HttpClientOptions - get() = client.httpClientOptions - override val webSocket: SocketApi - get() = client.webSocket - - override fun update( - baseUrl: String?, - accessToken: String?, - clientInfo: ClientInfo, - deviceInfo: DeviceInfo, - ) { - client.update(baseUrl, accessToken, clientInfo, deviceInfo) - } - - override suspend fun request( - method: HttpMethod, - pathTemplate: String, - pathParameters: Map<String, Any?>, - queryParameters: Map<String, Any?>, - requestBody: Any?, - ): RawResponse = - withContext(coroutineContext) { - client.request( - method, - pathTemplate, - pathParameters, - queryParameters, - requestBody, - ) - } -} - -class CoroutineContextApiClientFactory( - private val factory: OkHttpFactory, - private val coroutineContext: CoroutineContext = WholphinDispatchers.IO, -) : ApiClientFactory, - SocketConnectionFactory { - override fun create( - baseUrl: String?, - accessToken: String?, - clientInfo: ClientInfo, - deviceInfo: DeviceInfo, - httpClientOptions: HttpClientOptions, - socketConnectionFactory: SocketConnectionFactory, - ): ApiClient = - CoroutineContextApiClient( - factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), - coroutineContext, - ) - - override fun create( - clientOptions: HttpClientOptions, - scope: CoroutineScope, - ): SocketConnection = factory.create(clientOptions, scope) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index d83ae269..43d3e45a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -1,9 +1,8 @@ package com.github.damontecres.wholphin.util import android.content.Context -import com.github.damontecres.wholphin.data.MostRecentServer +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule -import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -13,7 +12,6 @@ import org.acra.sender.ReportSender import org.acra.sender.ReportSenderException import org.acra.sender.ReportSenderFactory import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -21,9 +19,6 @@ import org.json.JSONObject import timber.log.Timber import java.util.Date -/** - * Sends a crash report to the current server - */ @AutoService(ReportSenderFactory::class) class CrashReportSenderFactory : ReportSenderFactory { override fun create( @@ -40,10 +35,26 @@ class CrashReportSender : ReportSender { errorContent: CrashReportData, ) { Timber.v("Attempting to send crash report") - try { - val recentServer = AppModule.mostRecentServerProvider(context).get() - val api = recentServer.createApi(context) - if (api != null) { + val prefs = ServerRepository.getServerSharedPreferences(context) + val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null) + val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null) + if (serverUrl != null && accessToken != null) { + try { + val okHttpClient = + OkHttpClient + .Builder() + .build() + val okHttpFactory = OkHttpFactory(okHttpClient) + val api = + createJellyfin { + this.context = context + clientInfo = AppModule.clientInfo(context) + deviceInfo = AppModule.deviceInfo(context) + apiClientFactory = okHttpFactory + socketConnectionFactory = okHttpFactory + minimumServerVersion = Jellyfin.minimumVersion + }.createApi(baseUrl = serverUrl, accessToken = accessToken) + val obj = JSONObject() for ((key, value) in errorContent.toMap()) { obj.put(key, value) @@ -60,32 +71,13 @@ class CrashReportSender : ReportSender { """.trimIndent() + jsonStr, ).content.fileName - Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename) + Timber.i("Sent report to $serverUrl, filename=$filename") } - } else { - throw ReportSenderException("Could not find valid server and/or credentials to use") + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) } - } catch (ex: Exception) { - throw ReportSenderException("Exception while sending crash report", ex) + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") } } } - -private fun MostRecentServer.createApi(context: Context): ApiClient? = - (this as? MostRecentServer.ServerAndUser)?.let { - val okHttpClient = - OkHttpClient - .Builder() - .build() - val okHttpFactory = OkHttpFactory(okHttpClient) - val api = - createJellyfin { - this.context = context - clientInfo = AppModule.clientInfo(context) - deviceInfo = DeviceModule.deviceInfo(context) - apiClientFactory = okHttpFactory - socketConnectionFactory = okHttpFactory - minimumServerVersion = Jellyfin.minimumVersion - }.createApi(baseUrl = serverUrl, accessToken = accessToken) - api - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt deleted file mode 100644 index b7f2e2c6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DebugLogTree.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.util.Log -import com.github.damontecres.wholphin.BuildConfig -import timber.log.Timber - -/** - * Enable debug logging via [Timber] if enabled in the app settings - */ -class DebugLogTree private constructor() : Timber.Tree() { - // Only add logging for below INFO, production logger in WholphinApplication logs >=INFO - override fun isLoggable( - tag: String?, - priority: Int, - ): Boolean = priority < Log.INFO && !BuildConfig.DEBUG - - override fun log( - priority: Int, - tag: String?, - message: String, - t: Throwable?, - ) { - Log.println(priority, tag ?: "Wholphin", message) - } - - var enabled: Boolean - get() = Timber.forest().contains(this) - set(value) { - synchronized(this) { - if (value) { - if (!Timber.forest().contains(this)) { - Timber.plant(this) - } - } else { - if (Timber.forest().contains(this)) { - Timber.uproot(this) - } - } - } - } - - companion object { - val INSTANCE = DebugLogTree() - } -} 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 deleted file mode 100644 index ceb4f160..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DiscoverRequestPager.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.github.damontecres.wholphin.util - -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 - */ -class DiscoverRequestPager<T>( - private val api: SeerrApi, - val requestHandler: DiscoverRequestHandler<T>, - val transform: suspend (T) -> DiscoverItem, - scope: CoroutineScope, - pageSize: Int = SEERR_PAGE_SIZE, - cacheSize: Long = 16, -) : RequestPager<DiscoverItem>(scope, pageSize, cacheSize) { - override suspend fun init(initialPosition: Int): DiscoverRequestPager<T> = super.init(initialPosition) as DiscoverRequestPager<T> - - override suspend fun fetchPage( - pageNumber: Int, - includeTotalCount: Boolean, - ): QueryResult<DiscoverItem> { - val result = requestHandler.execute(api, pageNumber + 1) // Seerr pages are 1-indexed - val transformed = result.items.map { transform.invoke(it) } - return QueryResult(transformed, result.totalCount) - } -} - -const val SEERR_PAGE_SIZE = 20 - -enum class DiscoverRequestType( - @param:StringRes val stringRes: Int, -) { - DISCOVER_TV(R.string.discover_tv), - DISCOVER_MOVIES(R.string.discover_movies), - TRENDING(R.string.trending), - UPCOMING_TV(R.string.upcoming_tv), - UPCOMING_MOVIES(R.string.upcoming_movies), - 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 - */ -interface DiscoverRequestHandler<T> { - suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<T> -} - -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) - } -} - -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> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<SeerrSearchResult> = - api.api.searchApi.discoverTrendingGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } - -val UpcomingTvRequestHandler = - object : DiscoverRequestHandler<TvResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<TvResult> = - api.api.searchApi.discoverTvUpcomingGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } - -val UpcomingMovieRequestHandler = - object : DiscoverRequestHandler<MovieResult> { - override suspend fun execute( - api: SeerrApi, - pageNumber: Int, - ): QueryResult<MovieResult> = - api.api.searchApi.discoverMoviesUpcomingGet(page = pageNumber).let { - QueryResult(it.results.orEmpty(), it.totalResults ?: 0) - } - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt deleted file mode 100644 index b4f8e15f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt +++ /dev/null @@ -1,71 +0,0 @@ -@file:OptIn(UnstableApi::class) - -package com.github.damontecres.wholphin.util - -import androidx.annotation.OptIn -import androidx.media3.common.Format -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.UnstableApi -import com.github.damontecres.wholphin.preferences.ExperimentalPreferences -import com.github.damontecres.wholphin.preferences.enabled -import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest - -/** - * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this - * device, and how a converted track is described. - * - * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and - * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not - * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer. - * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the - * single layer stream that decoder does switch for. Nothing is re-encoded. - * - * The conversion has two halves which have to agree: - * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder; - * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because - * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would - * otherwise never be chosen for it. - * - * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile. - */ -object DolbyVisionProfile7Conversion { - /** - * True when both the user has enabled the setting and this device is one it helps: its Dolby - * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself - * is left alone, since it can use the enhancement layer the conversion would throw away, and - * a device without a profile 8 decoder has nothing to handle the converted stream - */ - fun isWanted( - experimental: ExperimentalPreferences, - mediaTest: MediaCodecCapabilitiesTest, - ): Boolean = - experimental.enabled { convertDolbyVisionProfile7 } && - !mediaTest.supportsHevcDolbyVisionEL() && - mediaTest.supportsHevcDolbyVisionProfile8() - - /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */ - fun isProfile7(format: Format): Boolean { - val codecs = format.codecs ?: return false - return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType && - (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07)) - } - - /** - * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes - * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they - * are: their samples reach the decoder through the secure path the filter does not touch, so - * advertising them as converted would be a lie. - */ - fun advertiseAsProfile8(format: Format): Format = - if (isProfile7(format) && format.drmInitData == null) { - format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build() - } else { - format - } - - /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */ - fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7) - - private const val CODECS_PREFIX_DVHE_07 = "dvhe.07" - private const val CODECS_PREFIX_DVH1_07 = "dvh1.07" -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt deleted file mode 100644 index 632f46c3..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt +++ /dev/null @@ -1,79 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import android.net.Uri -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.ForwardingExtractor -import androidx.media3.extractor.ForwardingExtractorOutput -import androidx.media3.extractor.ForwardingExtractorsFactory -import androidx.media3.extractor.ForwardingTrackOutput -import androidx.media3.extractor.TrackOutput -import timber.log.Timber - -/** - * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what - * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them. - * - * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not - * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on - * whichever decoder comes first in the list, with a warning that the format exceeds its - * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8 - * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same - * string. - * - * Only the codec string changes. Sample data is not touched here: the two convenience overloads of - * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted. - */ -class DolbyVisionProfile7ExtractorsFactory( - delegate: ExtractorsFactory, -) : ForwardingExtractorsFactory(delegate) { - override fun createExtractors(): Array<Extractor> = wrap(super.createExtractors()) - - override fun createExtractors( - uri: Uri, - responseHeaders: Map<String, List<String>>, - ): Array<Extractor> = wrap(super.createExtractors(uri, responseHeaders)) - - private fun wrap(extractors: Array<Extractor>): Array<Extractor> = - Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) } -} - -private class Profile8AdvertisingExtractor( - delegate: Extractor, -) : ForwardingExtractor(delegate) { - override fun init(output: ExtractorOutput) { - super.init(Profile8AdvertisingExtractorOutput(output)) - } -} - -private class Profile8AdvertisingExtractorOutput( - output: ExtractorOutput, -) : ForwardingExtractorOutput(output) { - override fun track( - id: Int, - type: Int, - ): TrackOutput { - val trackOutput = super.track(id, type) - return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput - } -} - -/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */ -internal class Profile8AdvertisingTrackOutput( - delegate: TrackOutput, -) : ForwardingTrackOutput(delegate) { - override fun format(format: Format) { - val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) - if (advertised !== format) { - Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs) - } - super.format(advertised) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt deleted file mode 100644 index 7cf50622..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt +++ /dev/null @@ -1,313 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi -import timber.log.Timber -import java.nio.Buffer -import java.nio.ByteBuffer - -/** - * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is - * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own. - * - * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long. - * That is the shape the native code reads: it takes the buffer's address and capacity, not its - * position and limit. - */ -interface RpuRewriter { - /** - * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is - * none it can parse. - */ - fun profileOf( - frame: ByteBuffer, - size: Int, - ): Int - - /** - * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new - * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's - * capacity must leave room past [size] for an RPU which comes out longer than it went in. - */ - fun convertToProfile8( - frame: ByteBuffer, - size: Int, - ): Int -} - -/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */ -class LibDoviRpuRewriter : RpuRewriter { - private val libDovi = LibDovi() - - override fun profileOf( - frame: ByteBuffer, - size: Int, - ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0 - - override fun convertToProfile8( - frame: ByteBuffer, - size: Int, - ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false) - - private companion object { - /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */ - const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1 - } -} - -/** - * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder. - * - * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the - * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU - * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby - * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a - * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and - * shows the HDR10 base layer, which is the very symptom this filter exists to fix. - * - * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is - * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer - * and is copied back only once it has succeeded and fits, so a refused access unit reaches the - * decoder untouched rather than half rewritten. - * - * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is - * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is - * called from its playback thread only. - */ -class DolbyVisionProfile7Filter( - private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() }, -) : BitstreamFilter { - private var rewriter: RpuRewriter? = null - private var rewriterUnavailable = false - - /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */ - private var staging = ByteArray(0) - - /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */ - private var scratch: ByteBuffer? = null - - private var convertedCount = 0 - private var passedThroughCount = 0 - private var refusedCount = 0 - - override fun filter( - data: ByteBuffer, - offset: Int, - size: Int, - ): Int { - if (!startsWithStartCode(data, offset, size)) { - return size - } - val rewriter = rewriter() ?: return size - - val staging = staging(size) - window(data, offset, size).get(staging, 0, size) - if (!carriesRpu(staging, size)) { - passedThroughCount++ - return size - } - - val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES) - val kept = copyWithoutEnhancementLayer(staging, size, scratch) - val converted = - try { - if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) { - passedThroughCount++ - return size - } - rewriter.convertToProfile8(scratch, kept) - } catch (e: RuntimeException) { - refuse("the RPU rewrite threw", e) - return size - } - if (converted <= 0 || converted > size) { - refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null) - return size - } - - window(data, offset, converted).put(window(scratch, 0, converted)) - convertedCount++ - return converted - } - - private fun rewriter(): RpuRewriter? { - if (rewriterUnavailable) { - return null - } - return rewriter ?: try { - newRewriter().also { rewriter = it } - } catch (e: LinkageError) { - // No libdovi for this ABI. Playback goes on exactly as it did without the filter. - Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is") - rewriterUnavailable = true - null - } - } - - private fun refuse( - reason: String, - e: Throwable?, - ) { - if (refusedCount == 0) { - Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason) - } - refusedCount++ - } - - private fun staging(size: Int): ByteArray { - if (staging.size < size) { - staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES)) - } - return staging - } - - private fun scratch(capacity: Int): ByteBuffer { - val current = scratch - val buffer = - if (current == null || current.capacity() < capacity) { - ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it } - } else { - current - } - (buffer as Buffer).clear() - return buffer - } - - override fun toString(): String = - "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " + - "$passedThroughCount passed through, $refusedCount refused)" - - private companion object { - const val DOLBY_VISION_PROFILE_7 = 7 - - /** HEVC NAL unit type carrying the Dolby Vision RPU. */ - const val NAL_UNIT_TYPE_RPU = 62 - - /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */ - const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63 - - /** Big enough for a UHD access unit without regrowing on every early frame. */ - const val INITIAL_CAPACITY_BYTES = 512 * 1024 - - /** - * Room past the access unit for an RPU which comes out of the rewrite longer than it went - * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose - * enhancement layer the extractor never delivered has none to free. - */ - const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024 - - private fun startsWithStartCode( - data: ByteBuffer, - offset: Int, - size: Int, - ): Boolean { - if (size < 4) { - return false - } - return data.get(offset) == 0.toByte() && - data.get(offset + 1) == 0.toByte() && - ( - data.get(offset + 2) == 1.toByte() || - (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) - ) - } - - /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */ - private fun carriesRpu( - source: ByteArray, - length: Int, - ): Boolean { - var unit = 0 - while (unit < length) { - val payload = unit + startCodeLength(source, unit) - if (payload >= length) { - return false - } - if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) { - return true - } - unit = nextStartCode(source, payload, length) - } - return false - } - - /** - * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0, - * leaving out every enhancement layer NAL unit, and returns the number of bytes copied. - */ - fun copyWithoutEnhancementLayer( - source: ByteArray, - length: Int, - target: ByteBuffer, - ): Int { - var unit = 0 - var written = 0 - while (unit < length) { - val payload = unit + startCodeLength(source, unit) - if (payload >= length) { - // A start code with nothing behind it. Not something to interpret; kept as it came. - target.put(source, unit, length - unit) - written += length - unit - break - } - val next = nextStartCode(source, payload, length) - if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) { - target.put(source, unit, next - unit) - written += next - unit - } - unit = next - } - return written - } - - /** - * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so - * that bulk copies leave the buffer's untouched. The position and limit calls go through - * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23. - */ - private fun window( - data: ByteBuffer, - offset: Int, - length: Int, - ): ByteBuffer { - val view = data.duplicate() - val buffer: Buffer = view - buffer.clear() - buffer.position(offset) - buffer.limit(offset + length) - return view - } - - /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */ - private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F - - /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */ - private fun startCodeLength( - data: ByteArray, - at: Int, - ): Int = if (data[at + 2] == 1.toByte()) 3 else 4 - - /** - * Index of the next Annex B start code at or after [from], or [limit] if there is none. A - * fourth leading zero byte counts as part of the start code, which is the form media3's - * extractors emit; three byte codes are walked all the same. - */ - private fun nextStartCode( - data: ByteArray, - from: Int, - limit: Int, - ): Int { - var i = from - while (i + 2 < limit) { - if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) { - return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i - } - i++ - } - return limit - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt new file mode 100644 index 00000000..7ee4d0f8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util + +import androidx.lifecycle.MutableLiveData + +/** + * A [MutableLiveData] that only notifies observers if the value does not equal the previous value + */ +class EqualityMutableLiveData<T> : MutableLiveData<T> { + constructor() : super() + constructor(value: T) : super(value) + + override fun setValue(value: T?) { + if (value != getValue()) { + super.setValue(value) + } + } + + fun setValueNoCheck(value: T?) { + super.setValue(value) + } + + override fun postValue(value: T?) { + if (value != getValue()) { + super.postValue(value) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt b/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt new file mode 100644 index 00000000..e44d531a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/FocusPair.kt @@ -0,0 +1,9 @@ +package com.github.damontecres.wholphin.util + +import androidx.compose.ui.focus.FocusRequester + +data class FocusPair( + val row: Int, + val column: Int, + val focusRequester: FocusRequester, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt deleted file mode 100644 index 7b5d73e0..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilter.kt +++ /dev/null @@ -1,266 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.container.NalUnitUtil -import java.nio.ByteBuffer - -/** - * Masks HDR10+ (SMPTE ST 2094-40) SEI messages in HEVC access units so that decoders ignore them. - * - * Some decoder pipelines (notably on MediaTek based TVs) latch on to HDR10+ when a stream carries - * both Dolby Vision RPUs and HDR10+ dynamic metadata, even when a Dolby Vision decoder is selected. - * Overwriting the SEI payload type of the HDR10+ messages with an undefined value makes decoders - * skip them without changing the size of the access unit, so no NAL units need to be removed or - * rewritten. - */ -object Hdr10PlusSeiFilter { - /** - * Highest VCL NAL unit type in H.265 Table 7-1. Prefix SEI NAL units always precede the VCL - * NAL units of an access unit, so the scan can stop at the first one. - */ - private const val MAX_VCL_NAL_UNIT_TYPE = 31 - - /** `user_data_registered_itu_t_t35`, the SEI payload type used by HDR10+ (H.265 D.2.1). */ - private const val PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35 = 4 - - /** - * An SEI payload type H.265 does not define. H.265 D.3.1 requires a decoder to ignore SEI - * messages whose payload type it does not recognise, so writing this over the payload type - * makes the decoder skip the message without anything being removed or resized. - */ - private const val PAYLOAD_TYPE_MASKED = 254 - - /** - * ITU-T T.35 header identifying an HDR10+ message: `itu_t_t35_country_code` 0xB5 (United - * States), `itu_t_t35_terminal_provider_code` 0x003C (Samsung, which defined HDR10+) and - * `itu_t_t35_terminal_provider_oriented_code` 0x0001. FFmpeg declares the first two as - * `ITU_T_T35_COUNTRY_CODE_US` and `ITU_T_T35_PROVIDER_CODE_SAMSUNG` in `libavcodec/itut35.h`. - */ - private val HDR10_PLUS_T35_HEADER = intArrayOf(0xB5, 0x00, 0x3C, 0x00, 0x01) - - /** - * Masks every HDR10+ SEI message found in the HEVC access unit in `[offset, offset + size)` by - * overwriting its SEI payload type in place. The access unit must contain Annex B start code - * delimited NAL units (which is what media3's extractors emit); buffers with any other layout - * are left untouched. The buffer's position and limit are not modified. - * - * @return the number of masked SEI messages - */ - fun maskHdr10PlusSeiMessages( - data: ByteBuffer, - offset: Int, - size: Int, - ): Int { - val end = offset + size - if (!startsWithStartCode(data, offset, end)) { - return 0 - } - var masked = 0 - var searchFrom = offset - while (searchFrom < end) { - val nalStart = findNalUnitStart(data, searchFrom, end) - if (nalStart < 0 || nalStart >= end) { - break - } - val nalEnd = findNalUnitEnd(data, nalStart, end) - // nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2 - val nalUnitType = (data.get(nalStart).toInt() shr 1) and 0x3F - if (nalUnitType <= MAX_VCL_NAL_UNIT_TYPE) { - // HDR10+ SEI messages precede the VCL NAL units, so stop scanning here - break - } - if (nalUnitType == NalUnitUtil.H265_NAL_UNIT_TYPE_PREFIX_SEI) { - masked += maskInSeiNalUnit(data, nalStart + 2, nalEnd) - } - searchFrom = nalEnd - } - return masked - } - - private fun startsWithStartCode( - data: ByteBuffer, - offset: Int, - end: Int, - ): Boolean { - if (end - offset < 4) { - return false - } - return data.get(offset) == 0.toByte() && - data.get(offset + 1) == 0.toByte() && - ( - data.get(offset + 2) == 1.toByte() || - (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) - ) - } - - /** - * Returns the index of the first NAL unit byte after the next start code, or -1 if none. - * Start codes are the Annex B byte stream format. - */ - private fun findNalUnitStart( - data: ByteBuffer, - from: Int, - end: Int, - ): Int { - for (i in from..end - 4) { - if (data.get(i) == 0.toByte() && - data.get(i + 1) == 0.toByte() && - data.get(i + 2) == 1.toByte() - ) { - return i + 3 - } - } - return -1 - } - - /** Returns the index just past the NAL unit starting at [from], i.e. the next start code or [end]. */ - private fun findNalUnitEnd( - data: ByteBuffer, - from: Int, - end: Int, - ): Int { - for (i in from..end - 3) { - if (data.get(i) == 0.toByte() && - data.get(i + 1) == 0.toByte() && - data.get(i + 2) == 1.toByte() - ) { - return i - } - } - return end - } - - /** - * Parses the SEI messages in the NAL unit payload in `[payloadStart, nalEnd)` per H.265 - * section D.2.1 and masks the payload type of each HDR10+ message. Parsing is conservative: - * on any malformed data it stops without touching the remainder. - */ - private fun maskInSeiNalUnit( - data: ByteBuffer, - payloadStart: Int, - nalEnd: Int, - ): Int { - // Trim trailing_zero_8bits so the last byte is the RBSP trailing bits - var end = nalEnd - while (end > payloadStart && data.get(end - 1) == 0.toByte()) { - end-- - } - val reader = RbspReader(data, payloadStart, end) - var masked = 0 - while (reader.bytesRemaining > 1) { - var payloadType = 0 - var b = reader.readByte() - while (b == 0xFF) { - payloadType += 255 - b = reader.readByte() - } - if (b < 0) { - return masked - } - val payloadTypePosition = reader.lastBytePosition - payloadType += b - - var payloadSize = 0 - b = reader.readByte() - while (b == 0xFF) { - payloadSize += 255 - b = reader.readByte() - } - if (b < 0) { - return masked - } - payloadSize += b - if (payloadSize > reader.bytesRemaining) { - return masked - } - - var remainingPayload = payloadSize - var isHdr10Plus = - payloadType == PAYLOAD_TYPE_USER_DATA_REGISTERED_ITU_T_T35 && - payloadSize >= HDR10_PLUS_T35_HEADER.size - if (isHdr10Plus) { - for (expected in HDR10_PLUS_T35_HEADER) { - remainingPayload-- - if (reader.readByte() != expected) { - isHdr10Plus = false - break - } - } - } - repeat(remainingPayload) { - if (reader.readByte() < 0) { - return masked - } - } - if (isHdr10Plus) { - data.put(payloadTypePosition, PAYLOAD_TYPE_MASKED.toByte()) - masked++ - } - } - return masked - } - - /** - * Reads RBSP bytes from `[position, end)`, skipping the `emulation_prevention_three_byte` - * that H.265 section 7.4.2 inserts after two consecutive zero bytes. - */ - private class RbspReader( - private val data: ByteBuffer, - private var position: Int, - private val end: Int, - ) { - private var zeroRun = 0 - - /** Raw buffer index of the byte returned by the last [readByte] call. */ - var lastBytePosition = -1 - private set - - /** Upper bound of the remaining RBSP bytes. */ - val bytesRemaining: Int - get() = end - position - - /** Returns the next RBSP byte, or -1 if the end was reached. */ - fun readByte(): Int { - if (position >= end) { - return -1 - } - var b = data.get(position).toInt() and 0xFF - if (zeroRun >= 2 && b == 0x03) { - // Skip the emulation_prevention_three_byte - zeroRun = 0 - position++ - if (position >= end) { - return -1 - } - b = data.get(position).toInt() and 0xFF - } - zeroRun = if (b == 0) zeroRun + 1 else 0 - lastBytePosition = position - position++ - return b - } - } -} - -/** - * The [BitstreamFilter] form of [Hdr10PlusSeiFilter]. Masking overwrites one byte per message, so - * the access unit keeps the size it was handed. Instances count what they masked, which is what - * the adapter logs when it is released. - */ -class Hdr10PlusMaskingFilter : BitstreamFilter { - private var maskedCount = 0 - - override fun filter( - data: ByteBuffer, - offset: Int, - size: Int, - ): Int { - maskedCount += Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(data, offset, size) - return size - } - - override fun toString(): String = "HDR10+ SEI masking ($maskedCount messages masked)" -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt new file mode 100644 index 00000000..3e9c8ea9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.util + +import android.widget.Toast +import androidx.lifecycle.MutableLiveData +import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.ui.showToast +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +/** + * A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine. + */ +class LoadingExceptionHandler( + private val loadingState: MutableLiveData<LoadingState>, + private val errorMessage: String?, + private val autoToast: Boolean = false, +) : CoroutineExceptionHandler { + override val key: CoroutineContext.Key<*> + get() = CoroutineExceptionHandler + + override fun handleException( + context: CoroutineContext, + exception: Throwable, + ) { + if (exception is CancellationException) { + // Don't log/toast cancellations + return + } + Timber.e(exception, "Exception in coroutine") + runBlocking { + withContext(Dispatchers.Main) { + loadingState.value = + LoadingState.Error( + message = errorMessage, + exception = exception, + ) + } + if (autoToast) { + showToast( + WholphinApplication.instance, + "Error: ${exception.message}", + Toast.LENGTH_LONG, + ) + } + } + } +} 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 7c618290..9bf07d62 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -1,14 +1,9 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.ui.util.StringProvider /** * Generic state for loading something from the API - * - * @see DataLoadingState */ sealed interface LoadingState { data object Pending : LoadingState @@ -49,29 +44,23 @@ sealed interface RowLoadingState { } sealed interface HomeRowLoadingState { - val title: StringProvider - - val completed: Boolean - get() = this is Success || this is Error + val title: String data class Pending( - override val title: StringProvider, + override val title: String, ) : HomeRowLoadingState data class Loading( - override val title: StringProvider, + override val title: String, ) : HomeRowLoadingState data class Success( - override val title: StringProvider, + override val title: String, val items: List<BaseItem?>, - val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), - val rowType: HomeRowConfig? = null, - val showViewMore: Boolean = true, ) : HomeRowLoadingState data class Error( - override val title: StringProvider, + override val title: String, val message: String? = null, val exception: Throwable? = null, ) : HomeRowLoadingState { @@ -79,30 +68,3 @@ sealed interface HomeRowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } - -/** - * Generic state for loading something from the API - * - * @see LoadingState - */ -sealed interface DataLoadingState<out T> { - data object Pending : DataLoadingState<Nothing> - - data object Loading : DataLoadingState<Nothing> - - data class Success<T>( - val data: T, - ) : DataLoadingState<T> - - data class Error( - val message: String? = null, - val exception: Throwable? = null, - ) : DataLoadingState<Nothing> { - constructor(exception: Throwable) : this(null, exception) - - val localizedMessage: String = - listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") - } -} - -val <T> DataLoadingState<T>.successValue: T? get() = (this as? DataLoadingState.Success<T>)?.data diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt deleted file mode 100644 index b88bcb8a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.damontecres.wholphin.util - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -object LocalDateSerializer : KSerializer<LocalDate> { - override val descriptor: SerialDescriptor - get() = SerialDescriptor("LocalDate", String.serializer().descriptor) - - override fun serialize( - encoder: Encoder, - value: LocalDate, - ) { - encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE.format(value)) - } - - override fun deserialize(decoder: Decoder): LocalDate = - decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } -} - -object LocalDateTimeSerializer : KSerializer<LocalDateTime> { - override val descriptor: SerialDescriptor - get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor) - - override fun serialize( - encoder: Encoder, - value: LocalDateTime, - ) { - encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value)) - } - - override fun deserialize(decoder: Decoder): LocalDateTime = - decoder - .decodeString() - .let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } -} 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 deleted file mode 100644 index e02a525e..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Media3SubtitleOverride.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 - -/** - * Utility class to override private fields in media3's [SubtitleView] - */ -@OptIn(UnstableApi::class) -class Media3SubtitleOverride( - val outlineThicknessPx: Float, -) { - companion object { - private lateinit var outputField: Field - private lateinit var classCanvasSubtitleOutput: Class<*> - private lateinit var paintersField: Field - private lateinit var classSubtitlePainter: Class<*> - private lateinit var outlineWidthField: Field - - var initialized: Boolean - private set - - init { - try { - // This is bad times - outputField = SubtitleView::class.java.getDeclaredField("output") - classCanvasSubtitleOutput = Class.forName("androidx.media3.ui.CanvasSubtitleOutput") - paintersField = classCanvasSubtitleOutput.getDeclaredField("painters") - classSubtitlePainter = Class.forName("androidx.media3.ui.SubtitlePainter") - outlineWidthField = classSubtitlePainter.getDeclaredField("outlineWidth") - outputField.isAccessible = true - outlineWidthField.isAccessible = true - paintersField.isAccessible = true - initialized = true - } catch (ex: Exception) { - Timber.w(ex, "Error initializing") - initialized = false - } - } - } - - fun apply(subtitleView: SubtitleView) { - try { - if (initialized) { - // Basically hijack the field that controls the subtitle outline size - val canvasSubtitleOutput = outputField.get(subtitleView) - val painters = paintersField.get(canvasSubtitleOutput) as List<*> - painters.forEach { painter -> - outlineWidthField.set(painter, outlineThicknessPx) - } - } - } catch (ex: Exception) { - Timber.w(ex, "Failed to apply") - } - } -} 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 new file mode 100644 index 00000000..1c69d947 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/RememberTabManager.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.util + +import com.github.damontecres.wholphin.preferences.UserPreferences +import java.util.UUID + +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 deleted file mode 100644 index 10f0f64f..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/RequestPager.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -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.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber -import java.util.function.Predicate - -/** - * Handles paging for an API request. You must call [init] prior to use. - * - * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. - * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. - * - * Finally, items are cached allow for backward and forward scrolling. - */ -abstract class RequestPager<T>( - protected val scope: CoroutineScope, - val pageSize: Int = DEFAULT_PAGE_SIZE, - cacheSize: Long = 8, -) : AbstractList<T?>(), - BlockingList<T?> { - protected var totalCount by mutableIntStateOf(-1) - protected var items by mutableStateOf(ItemList<T>(0, pageSize, mapOf())) - protected val mutex = Mutex() - protected val cachedPages = - CacheBuilder - .newBuilder() - .maximumSize(cacheSize) - .build<Int, MutableList<T>>() - - open suspend fun init(initialPosition: Int = 0): RequestPager<T> { - if (totalCount < 0) { - fetchPageBlocking(initialPosition, true) - } - return this - } - - override operator fun get(index: Int): T? { - if (index in 0..<totalCount) { - val item = items[index] - if (item == null) { - fetchPage(index) - } - return item - } else { - throw IndexOutOfBoundsException("$index of $totalCount") - } - } - - override suspend fun getBlocking(index: Int): T? { - if (index in 0..<totalCount) { - val item = items[index] - if (item == null) { - fetchPageBlocking(index, false) - return items[index] - } - return item - } else { - throw IndexOutOfBoundsException("$index of $totalCount") - } - } - - override suspend fun indexOfBlocking(predicate: Predicate<T?>): Int { - init(0) - for (i in 0 until totalCount) { - val currentItem = getBlocking(i) - if (currentItem != null && predicate.test(currentItem)) { - return i - } - } - return -1 - } - - override val size: Int - get() = totalCount - - private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - fetchPageBlocking(position, false) - } - - protected suspend fun fetchPageBlocking( - position: Int, - setTotalCount: Boolean, - ) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null || setTotalCount) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val result = fetchPage(pageNumber, setTotalCount) - if (setTotalCount && result.totalCount != null) { - totalCount = result.totalCount.coerceAtLeast(0) - } - cachedPages.put(pageNumber, result.items.toMutableList()) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) - } - } - } - - suspend fun refresh() { - cachedPages.asMap().keys.forEachIndexed { index, pageNum -> - if (DEBUG) Timber.v("refresh: pageNum=%s", pageNum) - fetchPageBlocking(pageNum * pageSize, index == 0) - } - } - - protected abstract suspend fun fetchPage( - pageNumber: Int, - includeTotalCount: Boolean, - ): QueryResult<T> - - companion object { - internal const val DEBUG = false - } - - class ItemList<T>( - val size: Int, - val pageSize: Int, - val pages: Map<Int, List<T>>, - ) { - operator fun get(position: Int): T? { - val page = position / pageSize - val data = pages[page] - if (data != null) { - val index = position % pageSize - if (index in data.indices) { - return data[index] - } else { - // This can happen when items are removed while scrolling - Timber.w( - "Index $index not in data: position=$position, data.size=${data.size}", - ) - return null - } - } else { - return null - } - } - } -} - -data class QueryResult<T>( - val items: List<T>, - val totalCount: Int?, -) 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 deleted file mode 100644 index dd0d0b38..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.github.damontecres.wholphin.util - -import com.github.damontecres.wholphin.data.model.BaseItem -import org.jellyfin.sdk.model.api.BaseItemKind -import java.util.Locale - -object SearchRelevance { - private val REGEX_PATTERN = Regex("^/(.+)/(i)?$") - - fun score( - item: BaseItem, - query: String, - ): Int = score(item.name, item.type, query) - - fun score( - name: String?, - type: BaseItemKind, - query: String, - ): Int { - 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(name, pattern, caseInsensitive) + typeBonus(type) - } - - return when { - name == q -> 0 - name.startsWith(q) -> 100 - name.contains(" $q") -> 200 - name.contains(q) -> 300 - else -> fuzzyScore(name, q) - } + typeBonus(type) - } - - private fun regexScore( - name: String, - pattern: String, - caseInsensitive: Boolean, - ): Int = - try { - val options = if (caseInsensitive) setOf(RegexOption.IGNORE_CASE) else emptySet() - if (Regex(pattern, options).containsMatchIn(name)) 250 else 400 - } catch (e: Exception) { - 400 - } - - private fun fuzzyScore( - name: String, - query: String, - ): Int { - val distance = levenshteinDistance(name, query) - val maxLen = maxOf(name.length, query.length) - val similarity = 1.0 - (distance.toDouble() / maxLen) - - return when { - similarity >= 0.8 -> 350 - similarity >= 0.6 -> 375 - else -> 400 - } - } - - private fun levenshteinDistance( - s1: String, - s2: String, - ): Int { - val m = s1.length - val n = s2.length - val dp = Array(m + 1) { IntArray(n + 1) } - for (i in 0..m) dp[i][0] = i - for (j in 0..n) dp[0][j] = j - for (i in 1..m) { - for (j in 1..n) { - dp[i][j] = - if (s1[i - 1] == s2[j - 1]) { - dp[i - 1][j - 1] - } else { - 1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) - } - } - } - return dp[m][n] - } - - private fun typeBonus(type: BaseItemKind): Int = - when (type) { - BaseItemKind.SERIES -> 0 - BaseItemKind.MOVIE -> 1 - BaseItemKind.BOX_SET -> 2 - BaseItemKind.EPISODE -> 10 - else -> 5 - } -} 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 2ab4c2f7..b0c1f593 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,13 +3,14 @@ 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 -import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo import org.jellyfin.sdk.model.api.PlaybackStartInfo @@ -19,8 +20,6 @@ import org.jellyfin.sdk.model.extensions.inWholeTicks 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 @@ -31,9 +30,10 @@ import kotlin.time.Duration.Companion.seconds class TrackActivityPlaybackListener( private val api: ApiClient, private val player: Player, - private val getState: () -> PlaybackItemState?, + val playback: CurrentPlayback, + val itemPlayback: ItemPlayback, ) : Player.Listener { - private val coroutineScope = CoroutineScope(WholphinDispatchers.Main) + private val coroutineScope = CoroutineScope(Dispatchers.Main) private val task: TimerTask = object : TimerTask() { override fun run() { @@ -50,53 +50,44 @@ class TrackActivityPlaybackListener( fun init() { launch("reportPlaybackStart") { - getState.invoke()?.let { state -> - Timber.v("reportPlaybackStart for ${state.itemId}") - api.playStateApi.reportPlaybackStart( - PlaybackStartInfo( - canSeek = true, - itemId = state.itemId, - isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying }, - playMethod = state.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - isMuted = false, - audioStreamIndex = state.audioStreamIndex, - subtitleStreamIndex = state.subtitleStreamIndex, - playSessionId = state.playSessionId, - liveStreamId = state.liveStreamId, - ), - ) - - val delay = 5.seconds.inWholeMilliseconds - // Every x seconds, check if the video is playing - TIMER.schedule(task, delay, delay) - initialized = true - } + Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = true, + itemId = itemPlayback.itemId, + isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + playMethod = playback.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, + ), + ) + val delay = 5.seconds.inWholeMilliseconds + // Every x seconds, check if the video is playing + TIMER.schedule(task, delay, delay) + initialized = true } } fun release() { -// player.removeListener(this) task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds 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.takeIf { it >= 0 }, - failed = false, - playSessionId = state.playSessionId, - liveStreamId = state.liveStreamId, - ), - ) - } + Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") + api.playStateApi.reportPlaybackStopped( + PlaybackStopInfo( + itemId = itemPlayback.itemId, + positionTicks = position.inWholeTicks, + failed = false, + playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, + ), + ) } } @@ -117,31 +108,29 @@ class TrackActivityPlaybackListener( private fun saveActivity(position: Long) { launch("saveActivity") { - getState.invoke()?.let { state -> - val calcPosition = - withContext(WholphinDispatchers.Main) { - (if (position >= 0) position else player.currentPosition) - } - if (calcPosition > 0) { - val isPaused = withContext(WholphinDispatchers.Main) { !player.isPlaying } - Timber.v("saveActivity: itemId=${state.itemId}, pos=$calcPosition") - api.playStateApi.reportPlaybackProgress( - PlaybackProgressInfo( - itemId = state.itemId, - positionTicks = calcPosition.milliseconds.inWholeTicks, - canSeek = true, - isPaused = isPaused, - isMuted = false, - playMethod = state.playMethod, - repeatMode = RepeatMode.REPEAT_NONE, - playbackOrder = PlaybackOrder.DEFAULT, - audioStreamIndex = state.audioStreamIndex, - subtitleStreamIndex = state.subtitleStreamIndex, - playSessionId = state.playSessionId, - liveStreamId = state.liveStreamId, - ), - ) + val calcPosition = + withContext(Dispatchers.Main) { + (if (position >= 0) position else player.currentPosition) } + if (calcPosition > 0) { + val isPaused = withContext(Dispatchers.Main) { !player.isPlaying } + Timber.v("saveActivity: itemId=${itemPlayback.itemId}, pos=$calcPosition") + api.playStateApi.reportPlaybackProgress( + PlaybackProgressInfo( + itemId = itemPlayback.itemId, + positionTicks = calcPosition.milliseconds.inWholeTicks, + canSeek = true, + isPaused = isPaused, + isMuted = false, + playMethod = playback.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + audioStreamIndex = itemPlayback.audioIndex.takeIf { itemPlayback.audioIndexEnabled }, + subtitleStreamIndex = itemPlayback.subtitleIndex.takeIf { itemPlayback.subtitleIndexEnabled }, + playSessionId = playback.playSessionId, + liveStreamId = playback.liveStreamId, + ), + ) } } } @@ -154,7 +143,7 @@ class TrackActivityPlaybackListener( try { block.invoke(this) } catch (ex: Exception) { - Timber.w(ex, "Exception during %s", name) + Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) } } } @@ -165,23 +154,3 @@ class TrackActivityPlaybackListener( private val TIMER by lazy { Timer("$TAG-timer", true) } } } - -data class PlaybackItemState( - val itemId: UUID, - val playMethod: PlayMethod, - val audioStreamIndex: Int? = null, - val subtitleStreamIndex: Int? = null, - val playSessionId: String? = null, - val liveStreamId: String? = null, -) { - constructor( - playback: CurrentPlayback, - ) : this( - itemId = playback.item.id, - playMethod = playback.playMethod, - 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 f3ac966d..ed7abe57 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,6 +7,7 @@ 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 @@ -29,42 +30,15 @@ data class TrackSupport( } else { val type = when (codecs) { - MimeTypes.TEXT_VTT -> { - "vtt" - } - - MimeTypes.APPLICATION_VOBSUB -> { - "vobsub" - } - - MimeTypes.APPLICATION_SUBRIP -> { - "srt" - } - - MimeTypes.TEXT_SSA -> { - "ssa" - } - - MimeTypes.APPLICATION_PGS -> { - "pgs" - } - - MimeTypes.APPLICATION_DVBSUBS -> { - "dvd" - } - - MimeTypes.APPLICATION_TTML -> { - "ttml" - } - - MimeTypes.TEXT_UNKNOWN -> { - "unknown" - } - - null -> { - "unknown" - } - + MimeTypes.TEXT_VTT -> "vtt" + MimeTypes.APPLICATION_VOBSUB -> "vobsub" + MimeTypes.APPLICATION_SUBRIP -> "srt" + MimeTypes.TEXT_SSA -> "ssa" + MimeTypes.APPLICATION_PGS -> "pgs" + MimeTypes.APPLICATION_DVBSUBS -> "dvd" + MimeTypes.APPLICATION_TTML -> "ttml" + MimeTypes.TEXT_UNKNOWN -> "unknown" + null -> "unknown" else -> { val split = codecs.split("/") if (split.size > 1) split[1] else codecs @@ -158,7 +132,7 @@ fun checkForSupport(tracks: Tracks): List<TrackSupport> = val reason = TrackSupportReason.fromInt(it.getTrackSupport(i)) add( TrackSupport( - format.id, + format.id + " (${format.idAsInt})", type, reason, it.isSelected, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt index a0bfc08b..61946d60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TransformList.kt @@ -1,8 +1,5 @@ package com.github.damontecres.wholphin.util -/** - * A utility class to lazily transforms items from the source list - */ class TransformList<S, T>( private val source: List<S>, private val transform: (S) -> T, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt index d3248935..03303ac8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Version.kt @@ -2,9 +2,6 @@ package com.github.damontecres.wholphin.util import kotlinx.serialization.Serializable -/** - * Represents a version in the format of `<major>.<minor>.<patch>-<numCommits>-g<gitSha>` as output by `git describe` - */ @Serializable data class Version( val major: Int, @@ -70,7 +67,7 @@ data class Version( private fun compareNumCommits(version: Version): Int = (this.numCommits ?: 0) - (version.numCommits ?: 0) override fun toString(): String = - if (numCommits != null && numCommits > 0 && hash != null) { + if (numCommits != null && hash != null) { "v$major.$minor.$patch-$numCommits-g$hash" } else { "v$major.$minor.$patch" 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 deleted file mode 100644 index 2a466742..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/WholphinDispatchers.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 new file mode 100644 index 00000000..52af2644 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -0,0 +1,334 @@ +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 + +// 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 = mutableListOf<EventObserver>() + + @JvmStatic + fun addObserver(o: EventObserver) { + synchronized(observers) { + observers.add(o) + } + } + + @JvmStatic + fun removeObserver(o: EventObserver) { + synchronized(observers) { + observers.remove(o) + } + } + + @JvmStatic + fun eventProperty( + property: String, + value: Long, + ) { + synchronized(observers) { + for (o in observers) { + o.eventProperty(property, value) + } + } + } + + @JvmStatic + fun eventProperty( + property: String, + value: Boolean, + ) { + synchronized(observers) { + for (o in observers) { + o.eventProperty(property, value) + } + } + } + + @JvmStatic + fun eventProperty( + property: String, + value: Double, + ) { + synchronized(observers) { + for (o in observers) { + o.eventProperty(property, value) + } + } + } + + @JvmStatic + fun eventProperty( + property: String, + value: String, + ) { + synchronized(observers) { + for (o in observers) { + o.eventProperty(property, value) + } + } + } + + @JvmStatic + fun eventProperty(property: String) { + synchronized(observers) { + for (o in observers) { + o.eventProperty(property) + } + } + } + + @JvmStatic + fun event(eventId: Int) { + synchronized(observers) { + for (o in observers) { + o.event(eventId) + } + } + } + + @JvmStatic + fun eventEndFile( + reason: Int, + error: Int, + ) { + synchronized(observers) { + for (o in observers) { + o.eventEndFile(reason, error) + } + } + } + + private val log_observers = mutableListOf<LogObserver>() + + @JvmStatic + fun addLogObserver(o: LogObserver) { + synchronized(log_observers) { + log_observers.add(o) + } + } + + @JvmStatic + fun removeLogObserver(o: LogObserver) { + synchronized(log_observers) { + log_observers.remove(o) + } + } + + @JvmStatic + fun logMessage( + prefix: String, + level: Int, + text: String, + ) { + synchronized(log_observers) { + 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 new file mode 100644 index 00000000..f9a0669a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVProperty.kt @@ -0,0 +1,46 @@ +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/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt new file mode 100644 index 00000000..1ba202eb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -0,0 +1,817 @@ +package com.github.damontecres.wholphin.util.mpv + +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +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.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_VIDEO_RECONFIG +import timber.log.Timber +import kotlin.concurrent.atomics.ExperimentalAtomicApi +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, + enableHardwareDecoding: Boolean, +) : BasePlayer(), + MPVLib.EventObserver, + TrackSelector.InvalidationListener { + companion object { + private const val DEBUG = false + } + + private var isPaused: Boolean = true + private var surface: Surface? = null + + private val looper = Util.getCurrentOrMainLooper() + private val handler = Handler(looper) + private val listeners = + ListenerSet<Player.Listener>( + looper, + Clock.DEFAULT, + ) { listener, eventFlags -> + listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) + } + private val availableCommands: Player.Commands + private val trackSelector = DefaultTrackSelector(context) + + private var mediaItem: MediaItem? = null + private var startPositionMs: Long = 0L + private var durationMs: Long = 0L + private var positionMs: Long = -1L + private var playbackState: Int = STATE_READY + + @Volatile + var isReleased = false + private set + + @Volatile + private var isLoadingFile = false + + init { + Timber.v("config-dir=${context.filesDir.path}") + 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", "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}") + + MPVLib.init() + + MPVLib.setOptionString("force-window", "no") + MPVLib.setOptionString("idle", "yes") +// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) + + MPVLib.addObserver(this) + 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() + 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, + ) { + throwIfReleased() + + if (DEBUG) Timber.v("setMediaItems") + mediaItems.firstOrNull()?.let { + mediaItem = it + if (surface != null) { + loadFile(it) + } + } + } + + override fun setMediaItems( + mediaItems: List<MediaItem>, + startIndex: Int, + startPositionMs: Long, + ) { + if (DEBUG) Timber.v("setMediaItems") + this.startPositionMs = startPositionMs + setMediaItems(mediaItems.subList(startIndex, mediaItems.size), false) + } + + 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") + durationMs = 0L + positionMs = -1L + playbackState = STATE_READY + } + + override fun getPlaybackState(): Int { + if (DEBUG) Timber.v("getPlaybackState") + return playbackState + } + + override fun getPlaybackSuppressionReason(): Int = Player.PLAYBACK_SUPPRESSION_REASON_NONE + + override fun getPlayerError(): PlaybackException? { + TODO("Not yet implemented") + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (isReleased) return + if (DEBUG) Timber.v("setPlayWhenReady") + if (playWhenReady) { + MPVLib.setPropertyBoolean("pause", false) + } else { + MPVLib.setPropertyBoolean("pause", true) + } + notifyListeners(EVENT_PLAY_WHEN_READY_CHANGED) { + onPlayWhenReadyChanged( + playWhenReady, + PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ) + } + } + + override fun getPlayWhenReady(): Boolean { + if (DEBUG) Timber.v("getPlayWhenReady") + if (isReleased) return false + val isPaused = MPVLib.getPropertyBoolean("pause") ?: this.isPaused + return !isPaused + } + + override fun setRepeatMode(repeatMode: Int) { + if (DEBUG) Timber.v("setRepeatMode") + } + + override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + if (DEBUG) Timber.v("setShuffleModeEnabled") + } + + override fun getShuffleModeEnabled(): Boolean = false + + override fun isLoading(): Boolean = 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") + MPVLib.setPropertyDouble("speed", playbackParameters.speed.toDouble()) + } + + override fun getPlaybackParameters(): PlaybackParameters { + if (DEBUG) Timber.v("getPlaybackParameters") + val speed = MPVLib.getPropertyDouble("speed")?.toFloat() ?: 1f + return PlaybackParameters(speed) + } + + override fun stop() { + if (DEBUG) Timber.v("stop") + if (isReleased) return + pause() + mediaItem = null + positionMs = -1L + durationMs = 0L + playbackState = STATE_IDLE + notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(false) } + notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { onPlaybackStateChanged(STATE_IDLE) } + } + + override fun release() { + Timber.i("release") + if (!isReleased) { + MPVLib.removeObserver(this) + clearVideoSurfaceView(null) + MPVLib.destroy() + } + isReleased = true + } + + override fun getCurrentTracks(): Tracks { + if (DEBUG) Timber.v("getCurrentTracks") + if (isReleased) return Tracks.EMPTY + return getTracks() + } + + 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 = getTracks() + if (C.TRACK_TYPE_TEXT in parameters.disabledTrackTypes) { + // Subtitles disabled + Timber.v("TrackSelection: disabling subtitles") + MPVLib.setPropertyString("sid", "no") + } + if (C.TRACK_TYPE_AUDIO in parameters.disabledTrackTypes) { + // Audio disabled + Timber.v("TrackSelection: disabling audio") + MPVLib.setPropertyString("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) { + MPVLib.setPropertyString(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") + // TODO + return Timeline.EMPTY + } + + 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") + if (isReleased) { + return durationMs + } + val duration = + MPVLib.getPropertyDouble("duration/full")?.seconds?.inWholeMilliseconds + ?: durationMs + return duration + } + + override fun getCurrentPosition(): Long { + if (DEBUG) Timber.v("getCurrentPosition") + if (isReleased) { + return positionMs + } + val position = + MPVLib.getPropertyDouble("time-pos/full")?.seconds?.inWholeMilliseconds + ?: positionMs + return position + } + + 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 MPVLib.getPropertyDouble("demuxer-cache-duration")?.seconds?.inWholeMilliseconds ?: 0 + } + + 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 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() + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + throwIfReleased() + if (DEBUG) Timber.v("setVideoSurfaceView") + val surface = surfaceView?.holder?.surface + if (surface != null) { + this.surface = surface + Timber.v("Queued attach") + MPVLib.attachSurface(surface) + MPVLib.setOptionString("force-window", "yes") + Timber.d("Attached surface") + mediaItem?.let(::loadFile) + if (mediaItem == null) { + Timber.w("mediaItem is null in setVideoSurfaceView") + } + } else { + clearVideoSurfaceView(null) + } + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + Timber.d("clearVideoSurfaceView") + MPVLib.detachSurface() + MPVLib.setPropertyString("vo", "null") + MPVLib.setPropertyString("force-window", "no") + mediaItem = 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 + val width = MPVLib.getPropertyInt("width") + val height = MPVLib.getPropertyInt("height") + return if (width != null && height != null) { + VideoSize(width, height) + } else { + VideoSize.UNKNOWN + } + } + + override fun getSurfaceSize(): Size = throw UnsupportedOperationException() + + 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 + } + MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) + } + + 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 -> positionMs = value.seconds.inWholeMilliseconds + } + } + + override fun eventProperty( + property: String, + value: Boolean, + ) { + if (DEBUG) Timber.v("eventPropertyBoolean: $property=$value") + when (property) { + MPVProperty.PAUSED -> { + isPaused = value + notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(!value) } + } + } + } + + 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 -> durationMs = value.seconds.inWholeMilliseconds + } + } + + override fun event(eventId: Int) { + when (eventId) { +// MPV_EVENT_START_FILE -> { +// } + MPV_EVENT_FILE_LOADED -> { + isLoadingFile = false + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } + Timber.d("event: MPV_EVENT_FILE_LOADED") + mediaItem?.let { + it.localConfiguration?.subtitleConfigurations?.forEach { + val url = it.uri.toString() + val title = it.label ?: "External Subtitles" + Timber.v("Adding external subtitle track '$title'") + MPVLib.command(arrayOf("sub-add", url, "auto", title)) + } + } + notifyListeners(EVENT_RENDERED_FIRST_FRAME) { onRenderedFirstFrame() } + notifyListeners(EVENT_IS_PLAYING_CHANGED) { onIsPlayingChanged(true) } + getTracks().let { + notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } + } + } + + MPV_EVENT_PLAYBACK_RESTART -> { + Timber.d("event: MPV_EVENT_PLAYBACK_RESTART") + getTracks().let { + notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } + } + } + + MPV_EVENT_AUDIO_RECONFIG -> { + Timber.d("event: MPV_EVENT_AUDIO_RECONFIG") + getTracks().let { + notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } + } + } + + MPV_EVENT_VIDEO_RECONFIG -> { + Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") + getTracks().let { + notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(it) } + } + } + + 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 -> { + 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 loadFile(mediaItem: MediaItem) { + isLoadingFile = true + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } + val url = mediaItem.localConfiguration?.uri.toString() + if (startPositionMs > 0) { + MPVLib.command( + arrayOf( + "loadfile", + url, + "replace", + "-1", + "start=${startPositionMs / 1000.0}", + ), + ) + } else { + MPVLib.command(arrayOf("loadfile", url, "replace", "-1")) + } + + MPVLib.setPropertyString("vo", "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, + ) { + handler.post { + listeners.queueEvent(eventId) { + block.invoke(it) + } + listeners.flushEvents() + } + } + + private fun getTracks(): 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() + .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() + + val trackGroup = TrackGroup(format) + val group = + Tracks.Group( + trackGroup, + false, + intArrayOf(C.FORMAT_HANDLED), + booleanArrayOf(isSelected), + ) + group + } else { + null + } + } + return Tracks(groups) + } + + override fun onTrackSelectionsInvalidated() { + // no-op + } + + var subtitleDelay: Double + get() { + if (isReleased) return 0.0 + return MPVLib.getPropertyDouble("sub-delay") ?: 0.0 + } + set(value) { + if (isReleased) return + MPVLib.setPropertyDouble("sub-delay", value) + } +} + +fun MPVLib.setPropertyColor( + property: String, + color: Color, +) = MPVLib.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/mpv/Utils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt deleted file mode 100644 index 18e67f63..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/Utils.kt +++ /dev/null @@ -1,11 +0,0 @@ -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/Codec.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt index ad661617..72c0bc65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt @@ -7,7 +7,6 @@ object Codec { const val `3GP` = "3gp" const val ASF = "asf" const val AVI = "avi" - const val DASH = "dash" const val DVR_MS = "dvr-ms" const val HLS = "hls" const val M2V = "m2v" @@ -68,7 +67,6 @@ object Codec { const val VP8 = "vp8" const val VP9 = "vp9" const val AV1 = "av1" - const val VC1 = "vc1" } object Subtitle { 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 f1384a27..2da77741 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -2,16 +2,17 @@ package com.github.damontecres.wholphin.util.profile // Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt -import android.media.MediaCodecInfo -import android.util.Size +import android.content.Context import androidx.media3.common.MimeTypes -import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52 +import com.github.damontecres.wholphin.preferences.UserPreferences +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.ProfileConditionValue import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod +import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile @@ -45,23 +46,21 @@ val supportedAudioCodecs = Codec.Audio.VORBIS, ) -// fun createDeviceProfile( -// context: Context, -// userPreferences: UserPreferences, -// serverVersion: ServerVersion?, -// ) = userPreferences.appPreferences.playbackPreferences.let { prefs -> -// createDeviceProfile( -// mediaTest = MediaCodecCapabilitiesTest(context), -// maxBitrate = prefs.maxBitrate.toInt(), -// isAC3Enabled = prefs.overrides.ac3Supported, -// downMixAudio = prefs.overrides.downmixStereo, -// assDirectPlay = prefs.overrides.directPlayAss, -// pgsDirectPlay = prefs.overrides.directPlayPgs, -// jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), -// ) -// } - -private fun Size.min(min: Int) = if (min > 0 && height > min) Size(width, min) else this +fun createDeviceProfile( + context: Context, + userPreferences: UserPreferences, + serverVersion: ServerVersion?, +) = userPreferences.appPreferences.playbackPreferences.let { prefs -> + createDeviceProfile( + mediaTest = MediaCodecCapabilitiesTest(context), + maxBitrate = prefs.maxBitrate.toInt(), + isAC3Enabled = prefs.overrides.ac3Supported, + downMixAudio = prefs.overrides.downmixStereo, + assDirectPlay = prefs.overrides.directPlayAss, + pgsDirectPlay = prefs.overrides.directPlayPgs, + jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), + ) +} fun createDeviceProfile( mediaTest: MediaCodecCapabilitiesTest, @@ -70,28 +69,17 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, - decodeAv1: Boolean, jellyfinTenEleven: Boolean, - preferAc3ForSurround: Boolean, - forceEnabledHdr: Set<String>, - forceDisabledHdr: Set<String>, - maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = when { - downMixAudio -> { - downmixSupportedAudioCodecs - } - - !isAC3Enabled -> { + downMixAudio -> downmixSupportedAudioCodecs + !isAC3Enabled -> supportedAudioCodecs .filterNot { it == Codec.Audio.EAC3 || it == Codec.Audio.AC3 } .toTypedArray() - } - else -> { - supportedAudioCodecs - } + else -> supportedAudioCodecs } val supportsHevc = mediaTest.supportsHevc() @@ -99,21 +87,14 @@ fun createDeviceProfile( val hevcMainLevel = mediaTest.getHevcMainLevel() val hevcMain10Level = mediaTest.getHevcMain10Level() val supportsAVC = mediaTest.supportsAVC() - val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52 + val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() val avcMainLevel = mediaTest.getAVCMainLevel() - val avcHigh10Level = - if (supportsHi10P52) { - MediaCodecInfo.CodecProfileLevel.AVCLevel52 - } else { - mediaTest.getAVCHigh10Level() - } + val avcHigh10Level = mediaTest.getAVCHigh10Level() val supportsAV1 = mediaTest.supportsAV1() val supportsAV1Main10 = mediaTest.supportsAV1Main10() - val supportsVC1 = mediaTest.supportsVc1() - val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264).min(maxResolution) - val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265).min(maxResolution) - val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1).min(maxResolution) - val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1).min(maxResolution) + val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264) + val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265) + val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1) // / HDR capabilities @@ -147,20 +128,10 @@ fun createDeviceProfile( if (supportsHevc) videoCodec(Codec.Video.HEVC) videoCodec(Codec.Video.H264) - if (preferAc3ForSurround) { - audioCodec(Codec.Audio.AC3) - } else { - audioCodec(*allowedAudioCodecs) - } + audioCodec(*allowedAudioCodecs) copyTimestamps = false enableSubtitlesInManifest = true - - if (maxResolution > 0) { - conditions { - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution - } - } } // Audio @@ -168,14 +139,9 @@ fun createDeviceProfile( type = DlnaProfileType.AUDIO context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.MP3 - if (preferAc3ForSurround) { - audioCodec(Codec.Audio.AC3) - } else { - audioCodec(Codec.Audio.AAC) - } + audioCodec(Codec.Audio.MP3) } // / Direct play profiles @@ -185,7 +151,6 @@ fun createDeviceProfile( container( Codec.Container.ASF, - Codec.Container.DASH, Codec.Container.HLS, Codec.Container.M4V, Codec.Container.MKV, @@ -206,7 +171,6 @@ fun createDeviceProfile( Codec.Video.HEVC, Codec.Video.MPEG, Codec.Video.MPEG2VIDEO, - Codec.Video.VC1, Codec.Video.VP8, Codec.Video.VP9, ) @@ -222,19 +186,6 @@ 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 @@ -242,20 +193,16 @@ fun createDeviceProfile( conditions { when { - !supportsAVC -> { - ProfileConditionValue.VIDEO_PROFILE equals "none" - } - - else -> { + !supportsAVC -> ProfileConditionValue.VIDEO_PROFILE equals "none" + else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull( "high", "main", "baseline", "constrained baseline", - if (supportsAVCHigh10) "high 10" else null, + if (supportsAVCHigh10) "main 10" else null, ) - } } } } @@ -329,17 +276,13 @@ fun createDeviceProfile( conditions { when { - !supportsHevc -> { - ProfileConditionValue.VIDEO_PROFILE equals "none" - } - - else -> { + !supportsHevc -> ProfileConditionValue.VIDEO_PROFILE equals "none" + else -> ProfileConditionValue.VIDEO_PROFILE inCollection listOfNotNull( "main", if (supportsHevcMain10) "main 10" else null, ) - } } } } @@ -379,7 +322,6 @@ fun createDeviceProfile( conditions { when { - decodeAv1 -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" !supportsAV1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" !supportsAV1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10" else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" @@ -387,19 +329,6 @@ fun createDeviceProfile( } } - // VC1 profile - codecProfile { - type = CodecType.VIDEO - codec = Codec.Video.VC1 - - conditions { - when { - !supportsVC1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" - else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" - } - } - } - // Get max resolutions for common codecs // AVC codecProfile { @@ -424,99 +353,68 @@ fun createDeviceProfile( } // AV1 - if (!decodeAv1) { - codecProfile { - type = CodecType.VIDEO - codec = Codec.Video.AV1 - - conditions { - ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height - } - } - } - - // VC1 codecProfile { type = CodecType.VIDEO - codec = Codec.Video.VC1 + codec = Codec.Video.AV1 conditions { - ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVC1.width - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVC1.height - } - } - - if (maxResolution > 0) { - listOf( - Codec.Video.MPEG, - Codec.Video.MPEG2VIDEO, - Codec.Video.VP8, - Codec.Video.VP9, - ).forEach { codecName -> - codecProfile { - type = CodecType.VIDEO - codec = codecName - - conditions { - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution - } - } + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height } } // / HDR exclude list + // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesAv1 = buildSet { - if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) + if (jellyfinTenEleven) add("DOVIInvalid") - if (!decodeAv1) { - if (!supportsAV1DolbyVision) { - add(WholphinVideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) - } - - if (!supportsAV1HDR10Plus) { - add(WholphinVideoRangeType.HDR10_PLUS.serialName) - - if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) - } + if (!supportsAV1DolbyVision) { + add(VideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") } - } - forceEnabledHdr + forceDisabledHdr + if (!supportsAV1HDR10Plus) { + add(VideoRangeType.HDR10_PLUS.serialName) + + if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + } + } + + // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) + if (jellyfinTenEleven) add("DOVIInvalid") if (!supportsHevcDolbyVisionEL) { if (jellyfinTenEleven) { - add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) - } + add("DOVIWithEL") + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") } if (!supportsHevcDolbyVision) { - add(WholphinVideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + add(VideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add( + "DOVIWithHDR10Plus", + ) } } } if (!supportsHevcHDR10Plus) { - add(WholphinVideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) + add(VideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) - add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + add("DOVIWithHDR10Plus") + add("DOVIWithELHDR10Plus") } - } - forceEnabledHdr + forceDisabledHdr + } // Display // Note: The codec profiles use a workaround to create correct behavior @@ -563,27 +461,6 @@ 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 d2a3c042..0a936de9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile import android.os.Build /** - * List of device models with known HEVC DoVi/HDR10+ playback issues. + * List of devie models with known HEVC DoVi/HDR10+ playback issues. */ private val modelsWithDoViHdr10PlusBug = listOf( @@ -14,30 +14,6 @@ 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 - * - * Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html - */ -private val modelsWithHi10P52Support = - listOf( - "AFTMA08C15", // Fire TV Stick 4K Plus (2025) - "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) - "AFTKA", // Amazon Fire TV 4K Max (1st Gen) - "AFTKM", // Amazon Fire TV 4K (2nd Gen) - "AFTMM", // Fire TV Stick 4K - 1st Gen (2018) - ) - object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug - val eac3HlsPassthroughBug = Build.MODEL in modelsWithEac3TsPassthroughBug - val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt index a769c400..c607e89c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.util.profile -// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.7/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt +// Copied from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/MediaCodecCapabilitiesTest.kt import android.content.Context import android.media.MediaCodecInfo.CodecProfileLevel @@ -9,7 +9,6 @@ import android.media.MediaFormat import android.os.Build import android.util.Size import androidx.core.content.ContextCompat -import androidx.media3.common.MimeTypes import timber.log.Timber class MediaCodecCapabilitiesTest( @@ -41,46 +40,11 @@ class MediaCodecCapabilitiesTest( -1 } } - } - - // Some devices (e.g., Fire OS) may support AV1 below the official API level - // Use the platform constant if the API level is met; otherwise fall back to the literal value - // Reference: - // https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/media/java/android/media/MediaCodecInfo.java - private object AV1ProfileLevel { - val ProfileMain10: Int by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - CodecProfileLevel.AV1ProfileMain10 - } else { - 0x2 - } - } - val ProfileMain10HDR10: Int by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - CodecProfileLevel.AV1ProfileMain10HDR10 - } else { - 0x1000 - } - } - val ProfileMain10HDR10Plus: Int by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - CodecProfileLevel.AV1ProfileMain10HDR10Plus - } else { - 0x2000 - } - } - val DolbyVisionProfile10: Int by lazy { + val Profile10: Int by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { CodecProfileLevel.DolbyVisionProfileDvav110 } else { - 0x400 - } - } - val Level5: Int by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - CodecProfileLevel.AV1Level5 - } else { - 0x1000 + -1 } } } @@ -125,36 +89,41 @@ class MediaCodecCapabilitiesTest( CodecProfileLevel.HEVCMainTierLevel62 to 186, ) - fun supportsAV1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1) + fun supportsAV1(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1) fun supportsAV1Main10(): Boolean = - hasDecoder( - MimeTypes.VIDEO_AV1, - AV1ProfileLevel.ProfileMain10, - AV1ProfileLevel.Level5, - ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_AV1, + CodecProfileLevel.AV1ProfileMain10, + CodecProfileLevel.AV1Level5, + ) fun supportsAV1DolbyVision(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && hasDecoder( - MimeTypes.VIDEO_DOLBY_VISION, - AV1ProfileLevel.DolbyVisionProfile10, + MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, + DolbyVisionProfiles.Profile10, CodecProfileLevel.DolbyVisionLevelHd24, ) fun supportsAV1HDR10(): Boolean = - hasDecoder( - MimeTypes.VIDEO_AV1, - AV1ProfileLevel.ProfileMain10HDR10, - AV1ProfileLevel.Level5, - ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_AV1, + CodecProfileLevel.AV1ProfileMain10HDR10, + CodecProfileLevel.AV1Level5, + ) fun supportsAV1HDR10Plus(): Boolean = - hasDecoder( - MimeTypes.VIDEO_AV1, - AV1ProfileLevel.ProfileMain10HDR10Plus, - AV1ProfileLevel.Level5, - ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_AV1, + CodecProfileLevel.AV1ProfileMain10HDR10Plus, + CodecProfileLevel.AV1Level5, + ) fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC) @@ -209,15 +178,6 @@ class MediaCodecCapabilitiesTest( ) && supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) - // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces - fun supportsHevcDolbyVisionProfile8(): Boolean = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && - hasDecoder( - MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, - DolbyVisionProfiles.Profile8, - CodecProfileLevel.DolbyVisionLevelHd24, - ) - fun supportsHevcHDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( @@ -254,8 +214,6 @@ class MediaCodecCapabilitiesTest( }?.second ?: 0 } - fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1) - private fun getDecoderLevel( mime: String, profile: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt deleted file mode 100644 index bd49a6c4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.util.profile - -import org.jellyfin.sdk.model.api.VideoRangeType - -/** - * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's - * [VideoRangeType] enum (added server-side in Jellyfin 10.11). - * - * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+ - */ -enum class WholphinVideoRangeType( - val serialName: String, -) { - UNKNOWN(VideoRangeType.UNKNOWN.serialName), - SDR(VideoRangeType.SDR.serialName), - HDR10(VideoRangeType.HDR10.serialName), - HLG(VideoRangeType.HLG.serialName), - DOVI(VideoRangeType.DOVI.serialName), - DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName), - DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName), - DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName), - HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName), - DOVI_INVALID("DOVIInvalid"), - DOVI_WITH_EL("DOVIWithEL"), - DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"), - DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"), -} diff --git a/app/src/main/jni/Android.mk b/app/src/main/jni/Android.mk new file mode 100644 index 00000000..798ae9a2 --- /dev/null +++ b/app/src/main/jni/Android.mk @@ -0,0 +1,82 @@ +LOCAL_PATH:= $(call my-dir) + +ifeq ($(TARGET_ARCH_ABI),armeabi-v7a) +PREFIX = $(PREFIX32) +endif +ifeq ($(TARGET_ARCH_ABI),arm64-v8a) +PREFIX = $(PREFIX64) +endif +ifeq ($(TARGET_ARCH_ABI),x86_64) +PREFIX = $(PREFIX_X64) +endif +ifeq ($(TARGET_ARCH_ABI),x86) +PREFIX = $(PREFIX_X86) +endif + +include $(CLEAR_VARS) +LOCAL_MODULE := libswresample +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libpostproc +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +# only include if library file exists +ifneq (,$(wildcard $(LOCAL_SRC_FILES))) +include $(PREBUILT_SHARED_LIBRARY) +endif + +include $(CLEAR_VARS) +LOCAL_MODULE := libavutil +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libavcodec +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libavformat +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libswscale +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libavfilter +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libavdevice +LOCAL_SRC_FILES := $(PREFIX)/lib/$(LOCAL_MODULE).so +LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) +LOCAL_MODULE := libmpv +LOCAL_SRC_FILES := $(PREFIX)/lib/libmpv.so +LOCAL_EXPORT_C_INCLUDES := $(PREFIX)/include +include $(PREBUILT_SHARED_LIBRARY) + +include $(CLEAR_VARS) + +LOCAL_MODULE := libplayer +LOCAL_CFLAGS := -Werror +LOCAL_CPPFLAGS += -std=c++11 +LOCAL_SRC_FILES := \ + main.cpp \ + render.cpp \ + log.cpp \ + jni_utils.cpp \ + property.cpp \ + event.cpp \ + thumbnail.cpp +LOCAL_LDLIBS := -llog -lGLESv3 -lEGL -latomic +LOCAL_SHARED_LIBRARIES := swscale avcodec mpv + +include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk new file mode 100644 index 00000000..53418cb4 --- /dev/null +++ b/app/src/main/jni/Application.mk @@ -0,0 +1,17 @@ +APP_ABI := +ifneq ($(PREFIX32),) +APP_ABI += armeabi-v7a +endif +ifneq ($(PREFIX64),) +APP_ABI += arm64-v8a +endif +ifneq ($(PREFIX_X64),) +APP_ABI += x86_64 +endif +ifneq ($(PREFIX_X86),) +APP_ABI += x86 +endif + +APP_PLATFORM := android-21 +APP_STL := c++_shared +APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true diff --git a/app/src/main/jni/README.md b/app/src/main/jni/README.md new file mode 100644 index 00000000..15d82972 --- /dev/null +++ b/app/src/main/jni/README.md @@ -0,0 +1,16 @@ +# JNI bindings for libmpv + +The files in this directory are copied from https://github.com/mpv-android/mpv-android/tree/master/app/src/main/jni + +## License & copyright + +The files are copyrighted and used under MIT license below: + +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. diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp new file mode 100644 index 00000000..b092b661 --- /dev/null +++ b/app/src/main/jni/event.cpp @@ -0,0 +1,120 @@ +#include <jni.h> + +#include <mpv/client.h> + +#include "globals.h" +#include "jni_utils.h" +#include "log.h" + +static void sendPropertyUpdateToJava(JNIEnv *env, mpv_event_property *prop) +{ + jstring jprop = env->NewStringUTF(prop->name); + jstring jvalue = NULL; + switch (prop->format) { + case MPV_FORMAT_NONE: + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_S, jprop); + break; + case MPV_FORMAT_FLAG: + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sb, jprop, + (jboolean) (*(int*)prop->data != 0)); + break; + case MPV_FORMAT_INT64: + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sl, jprop, + (jlong) *(int64_t*)prop->data); + break; + case MPV_FORMAT_DOUBLE: + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_Sd, jprop, + (jdouble) *(double*)prop->data); + break; + case MPV_FORMAT_STRING: + jvalue = env->NewStringUTF(*(const char**)prop->data); + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_eventProperty_SS, jprop, jvalue); + break; + default: + ALOGV("sendPropertyUpdateToJava: Unknown property update format received in callback: %d!", prop->format); + break; + } + if (jprop) + env->DeleteLocalRef(jprop); + if (jvalue) + env->DeleteLocalRef(jvalue); +} + +static void sendEventToJava(JNIEnv *env, int event) +{ + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_event, event); +} + +static void sendEndFileEventToJava(JNIEnv *env, mpv_event_end_file *event) +{ + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_end_file_event, event->reason, event->error); +} + +static void sendLogMessageToJava(JNIEnv *env, mpv_event_log_message *msg) +{ + // filter the most obvious cases of invalid utf-8, since Java would choke on it + const auto invalid_utf8 = [] (unsigned char c) { + return c == 0xc0 || c == 0xc1 || c >= 0xf5; + }; + for (int i = 0; msg->text[i]; i++) { + if (invalid_utf8(static_cast<unsigned char>(msg->text[i]))) + return; + } + + jstring jprefix = env->NewStringUTF(msg->prefix); + jstring jtext = env->NewStringUTF(msg->text); + + env->CallStaticVoidMethod(mpv_MPVLib, mpv_MPVLib_logMessage_SiS, + jprefix, (jint) msg->log_level, jtext); + + if (jprefix) + env->DeleteLocalRef(jprefix); + if (jtext) + env->DeleteLocalRef(jtext); +} + +void *event_thread(void *arg) +{ + JNIEnv *env = NULL; + acquire_jni_env(g_vm, &env); + if (!env) + die("failed to acquire java env"); + + while (1) { + mpv_event *mp_event; + mpv_event_property *mp_property = NULL; + mpv_event_log_message *msg = NULL; + mpv_event_end_file *mp_end_file = NULL; + + mp_event = mpv_wait_event(g_mpv, -1.0); + + if (g_event_thread_request_exit) + break; + + if (mp_event->event_id == MPV_EVENT_NONE) + continue; + + switch (mp_event->event_id) { + case MPV_EVENT_LOG_MESSAGE: + msg = (mpv_event_log_message*)mp_event->data; + ALOGV("[%s:%s] %s", msg->prefix, msg->level, msg->text); + sendLogMessageToJava(env, msg); + break; + case MPV_EVENT_PROPERTY_CHANGE: + mp_property = (mpv_event_property*)mp_event->data; + sendPropertyUpdateToJava(env, mp_property); + break; + case MPV_EVENT_END_FILE: + mp_end_file = (mpv_event_end_file*)mp_event->data; + sendEndFileEventToJava(env, mp_end_file); + default: + ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); + sendEventToJava(env, mp_event->event_id); + break; + } + } + + g_vm->DetachCurrentThread(); + + return NULL; +} diff --git a/app/src/main/jni/event.h b/app/src/main/jni/event.h new file mode 100644 index 00000000..ebb20d2d --- /dev/null +++ b/app/src/main/jni/event.h @@ -0,0 +1,3 @@ +#pragma once + +void *event_thread(void *arg); diff --git a/app/src/main/jni/globals.h b/app/src/main/jni/globals.h new file mode 100644 index 00000000..e2427b8b --- /dev/null +++ b/app/src/main/jni/globals.h @@ -0,0 +1,7 @@ +#pragma once + +#include <atomic> + +extern JavaVM *g_vm; +extern mpv_handle *g_mpv; +extern std::atomic<bool> g_event_thread_request_exit; diff --git a/app/src/main/jni/jni_utils.cpp b/app/src/main/jni/jni_utils.cpp new file mode 100644 index 00000000..f878fdff --- /dev/null +++ b/app/src/main/jni/jni_utils.cpp @@ -0,0 +1,52 @@ +#define UTIL_EXTERN +#include "jni_utils.h" + +#include <jni.h> +#include <stdlib.h> + +bool acquire_jni_env(JavaVM *vm, JNIEnv **env) +{ + int ret = vm->GetEnv((void**) env, JNI_VERSION_1_6); + if (ret == JNI_EDETACHED) + return vm->AttachCurrentThread(env, NULL) == 0; + else + return ret == JNI_OK; +} + +// Apparently it's considered slow to FindClass and GetMethodID every time we need them, +// so let's have a nice cache here. + +void init_methods_cache(JNIEnv *env) +{ + static bool methods_initialized = false; + if (methods_initialized) + return; + + #define FIND_CLASS(name) reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(name))) + java_Integer = FIND_CLASS("java/lang/Integer"); + java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V"); + java_Double = FIND_CLASS("java/lang/Double"); + java_Double_init = env->GetMethodID(java_Double, "<init>", "(D)V"); + java_Boolean = FIND_CLASS("java/lang/Boolean"); + java_Boolean_init = env->GetMethodID(java_Boolean, "<init>", "(Z)V"); + + android_graphics_Bitmap = FIND_CLASS("android/graphics/Bitmap"); + // createBitmap(int[], int, int, android.graphics.Bitmap$Config) + android_graphics_Bitmap_createBitmap = env->GetStaticMethodID(android_graphics_Bitmap, "createBitmap", "([IIILandroid/graphics/Bitmap$Config;)Landroid/graphics/Bitmap;"); + android_graphics_Bitmap_Config = FIND_CLASS("android/graphics/Bitmap$Config"); + // static final android.graphics.Bitmap$Config ARGB_8888 + android_graphics_Bitmap_Config_ARGB_8888 = env->GetStaticFieldID(android_graphics_Bitmap_Config, "ARGB_8888", "Landroid/graphics/Bitmap$Config;"); + + mpv_MPVLib = FIND_CLASS("com/github/damontecres/wholphin/util/mpv/MPVLib"); + mpv_MPVLib_eventProperty_S = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;)V"); // eventProperty(String) + mpv_MPVLib_eventProperty_Sb = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Z)V"); // eventProperty(String, boolean) + mpv_MPVLib_eventProperty_Sl = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;J)V"); // eventProperty(String, long) + mpv_MPVLib_eventProperty_Sd = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;D)V"); // eventProperty(String, double) + mpv_MPVLib_eventProperty_SS = env->GetStaticMethodID(mpv_MPVLib, "eventProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); // eventProperty(String, String) + mpv_MPVLib_event = env->GetStaticMethodID(mpv_MPVLib, "event", "(I)V"); // event(int) + mpv_MPVLib_end_file_event = env->GetStaticMethodID(mpv_MPVLib, "eventEndFile", "(II)V"); // eventEndFile(int, int) + mpv_MPVLib_logMessage_SiS = env->GetStaticMethodID(mpv_MPVLib, "logMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); // logMessage(String, int, String) + #undef FIND_CLASS + + methods_initialized = true; +} diff --git a/app/src/main/jni/jni_utils.h b/app/src/main/jni/jni_utils.h new file mode 100644 index 00000000..ed9d025d --- /dev/null +++ b/app/src/main/jni/jni_utils.h @@ -0,0 +1,30 @@ +#pragma once + +#include <jni.h> + +#define jni_func_name(name) Java_com_github_damontecres_wholphin_util_mpv_MPVLib_##name +#define jni_func(return_type, name, ...) JNIEXPORT return_type JNICALL jni_func_name(name) (JNIEnv *env, jobject obj, ##__VA_ARGS__) + +bool acquire_jni_env(JavaVM *vm, JNIEnv **env); +void init_methods_cache(JNIEnv *env); + +#ifndef UTIL_EXTERN +#define UTIL_EXTERN extern +#endif + +UTIL_EXTERN jclass java_Integer, java_Double, java_Boolean; +UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init; + +UTIL_EXTERN jclass android_graphics_Bitmap, android_graphics_Bitmap_Config; +UTIL_EXTERN jmethodID android_graphics_Bitmap_createBitmap; +UTIL_EXTERN jfieldID android_graphics_Bitmap_Config_ARGB_8888; + +UTIL_EXTERN jclass mpv_MPVLib; +UTIL_EXTERN jmethodID mpv_MPVLib_eventProperty_S, + mpv_MPVLib_eventProperty_Sb, + mpv_MPVLib_eventProperty_Sl, + mpv_MPVLib_eventProperty_Sd, + mpv_MPVLib_eventProperty_SS, + mpv_MPVLib_event, + mpv_MPVLib_end_file_event, + mpv_MPVLib_logMessage_SiS; diff --git a/app/src/main/jni/log.cpp b/app/src/main/jni/log.cpp new file mode 100644 index 00000000..240af1bc --- /dev/null +++ b/app/src/main/jni/log.cpp @@ -0,0 +1,9 @@ +#include "log.h" + +#include <stdlib.h> + +void die(const char *msg) +{ + ALOGE("%s", msg); + exit(1); +} diff --git a/app/src/main/jni/log.h b/app/src/main/jni/log.h new file mode 100644 index 00000000..58fbda05 --- /dev/null +++ b/app/src/main/jni/log.h @@ -0,0 +1,20 @@ +#pragma once + +#include <android/log.h> + +#define DEBUG 1 + +#define LOG_TAG "mpv" +#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#if DEBUG +#define ALOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__) +#else +#define ALOGV(...) (void)0 +#endif + +__attribute__((noreturn)) void die(const char *msg); + +#define CHECK_MPV_INIT() do { \ + if (__builtin_expect(!g_mpv, 0)) \ + die("libmpv is not initialized"); \ + } while (0) diff --git a/app/src/main/jni/main.cpp b/app/src/main/jni/main.cpp new file mode 100644 index 00000000..5f3faedc --- /dev/null +++ b/app/src/main/jni/main.cpp @@ -0,0 +1,108 @@ +#include <jni.h> +#include <stdlib.h> +#include <stdio.h> +#include <time.h> +#include <locale.h> +#include <atomic> + +#include <mpv/client.h> + +#include <pthread.h> + +extern "C" { + #include <libavcodec/jni.h> +} + +#include "log.h" +#include "jni_utils.h" +#include "event.h" + +#define ARRAYLEN(a) (sizeof(a)/sizeof(a[0])) + +extern "C" { + jni_func(void, create, jobject appctx); + jni_func(void, init); + jni_func(void, destroy); + + jni_func(void, command, jobjectArray jarray); +}; + +JavaVM *g_vm; +mpv_handle *g_mpv; +std::atomic<bool> g_event_thread_request_exit(false); + +static pthread_t event_thread_id; + +static void prepare_environment(JNIEnv *env, jobject appctx) { + setlocale(LC_NUMERIC, "C"); + + if (!env->GetJavaVM(&g_vm) && g_vm) + av_jni_set_java_vm(g_vm, NULL); + + jobject global_appctx = env->NewGlobalRef(appctx); + if (global_appctx) + av_jni_set_android_app_ctx(global_appctx, NULL); + + init_methods_cache(env); +} + +jni_func(void, create, jobject appctx) { + prepare_environment(env, appctx); + + if (g_mpv) + die("mpv is already initialized"); + + g_mpv = mpv_create(); + if (!g_mpv) + die("context init failed"); + + // use terminal log level but request verbose messages + // this way --msg-level can be used to adjust later + mpv_request_log_messages(g_mpv, "terminal-default"); + mpv_set_option_string(g_mpv, "msg-level", "all=v"); +} + +jni_func(void, init) { + if (!g_mpv) + die("mpv is not created"); + + if (mpv_initialize(g_mpv) < 0) + die("mpv init failed"); + + g_event_thread_request_exit = false; + if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) + die("thread create failed"); + pthread_setname_np(event_thread_id, "event_thread"); +} + +jni_func(void, destroy) { + if (!g_mpv) { + ALOGV("mpv destroy called but it's already destroyed"); + return; + } + + // poke event thread and wait for it to exit + g_event_thread_request_exit = true; + mpv_wakeup(g_mpv); + pthread_join(event_thread_id, NULL); + + mpv_terminate_destroy(g_mpv); + g_mpv = NULL; +} + +jni_func(void, command, jobjectArray jarray) { + CHECK_MPV_INIT(); + + const char *arguments[128] = {0}; + int len = env->GetArrayLength(jarray); + if (len >= ARRAYLEN(arguments)) + die("too many command arguments"); + + for (int i = 0; i < len; ++i) + arguments[i] = env->GetStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), NULL); + + mpv_command(g_mpv, arguments); + + for (int i = 0; i < len; ++i) + env->ReleaseStringUTFChars((jstring)env->GetObjectArrayElement(jarray, i), arguments[i]); +} diff --git a/app/src/main/jni/property.cpp b/app/src/main/jni/property.cpp new file mode 100644 index 00000000..a9558ab6 --- /dev/null +++ b/app/src/main/jni/property.cpp @@ -0,0 +1,125 @@ +#include <jni.h> +#include <stdlib.h> + +#include <mpv/client.h> + +#include "jni_utils.h" +#include "log.h" +#include "globals.h" + +extern "C" { + jni_func(jint, setOptionString, jstring option, jstring value); + + jni_func(jobject, getPropertyInt, jstring property); + jni_func(void, setPropertyInt, jstring property, jint value); + jni_func(jobject, getPropertyDouble, jstring property); + jni_func(void, setPropertyDouble, jstring property, jdouble value); + jni_func(jobject, getPropertyBoolean, jstring property); + jni_func(void, setPropertyBoolean, jstring property, jboolean value); + jni_func(jstring, getPropertyString, jstring jproperty); + jni_func(void, setPropertyString, jstring jproperty, jstring jvalue); + + jni_func(void, observeProperty, jstring property, jint format); +} + +jni_func(jint, setOptionString, jstring joption, jstring jvalue) { + CHECK_MPV_INIT(); + + const char *option = env->GetStringUTFChars(joption, NULL); + const char *value = env->GetStringUTFChars(jvalue, NULL); + + int result = mpv_set_option_string(g_mpv, option, value); + + env->ReleaseStringUTFChars(joption, option); + env->ReleaseStringUTFChars(jvalue, value); + + return result; +} + +static int common_get_property(JNIEnv *env, jstring jproperty, mpv_format format, void *output) +{ + CHECK_MPV_INIT(); + + const char *prop = env->GetStringUTFChars(jproperty, NULL); + int result = mpv_get_property(g_mpv, prop, format, output); + if (result == MPV_ERROR_PROPERTY_UNAVAILABLE) + ALOGV("mpv_get_property(%s) format %d was unavailable", prop, format); + else if (result < 0) + ALOGE("mpv_get_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); + env->ReleaseStringUTFChars(jproperty, prop); + + return result; +} + +static int common_set_property(JNIEnv *env, jstring jproperty, mpv_format format, void *value) +{ + CHECK_MPV_INIT(); + + const char *prop = env->GetStringUTFChars(jproperty, NULL); + int result = mpv_set_property(g_mpv, prop, format, value); + if (result < 0) + ALOGE("mpv_set_property(%s, %p) format %d returned error %s", prop, value, format, mpv_error_string(result)); + env->ReleaseStringUTFChars(jproperty, prop); + + return result; +} + +jni_func(jobject, getPropertyInt, jstring jproperty) { + int64_t value = 0; + if (common_get_property(env, jproperty, MPV_FORMAT_INT64, &value) < 0) + return NULL; + return env->NewObject(java_Integer, java_Integer_init, (jint)value); +} + +jni_func(jobject, getPropertyDouble, jstring jproperty) { + double value = 0; + if (common_get_property(env, jproperty, MPV_FORMAT_DOUBLE, &value) < 0) + return NULL; + return env->NewObject(java_Double, java_Double_init, (jdouble)value); +} + +jni_func(jobject, getPropertyBoolean, jstring jproperty) { + int value = 0; + if (common_get_property(env, jproperty, MPV_FORMAT_FLAG, &value) < 0) + return NULL; + return env->NewObject(java_Boolean, java_Boolean_init, (jboolean)value); +} + +jni_func(jstring, getPropertyString, jstring jproperty) { + char *value; + if (common_get_property(env, jproperty, MPV_FORMAT_STRING, &value) < 0) + return NULL; + jstring jvalue = env->NewStringUTF(value); + mpv_free(value); + return jvalue; +} + +jni_func(void, setPropertyInt, jstring jproperty, jint jvalue) { + int64_t value = static_cast<int64_t>(jvalue); + common_set_property(env, jproperty, MPV_FORMAT_INT64, &value); +} + +jni_func(void, setPropertyDouble, jstring jproperty, jdouble jvalue) { + double value = static_cast<double>(jvalue); + common_set_property(env, jproperty, MPV_FORMAT_DOUBLE, &value); +} + +jni_func(void, setPropertyBoolean, jstring jproperty, jboolean jvalue) { + int value = jvalue == JNI_TRUE ? 1 : 0; + common_set_property(env, jproperty, MPV_FORMAT_FLAG, &value); +} + +jni_func(void, setPropertyString, jstring jproperty, jstring jvalue) { + const char *value = env->GetStringUTFChars(jvalue, NULL); + common_set_property(env, jproperty, MPV_FORMAT_STRING, &value); + env->ReleaseStringUTFChars(jvalue, value); +} + +jni_func(void, observeProperty, jstring property, jint format) { + CHECK_MPV_INIT(); + const char *prop = env->GetStringUTFChars(property, NULL); + int result = mpv_observe_property(g_mpv, 0, prop, (mpv_format)format); + if (result < 0) + ALOGE("mpv_observe_property(%s) format %d returned error %s", prop, format, mpv_error_string(result)); + env->ReleaseStringUTFChars(property, prop); +} diff --git a/app/src/main/jni/render.cpp b/app/src/main/jni/render.cpp new file mode 100644 index 00000000..1df2bb5a --- /dev/null +++ b/app/src/main/jni/render.cpp @@ -0,0 +1,38 @@ +#include <jni.h> + +#include <mpv/client.h> + +#include "jni_utils.h" +#include "log.h" +#include "globals.h" + +extern "C" { + jni_func(void, attachSurface, jobject surface_); + jni_func(void, detachSurface); +}; + +static jobject surface; + +jni_func(void, attachSurface, jobject surface_) { + CHECK_MPV_INIT(); + + surface = env->NewGlobalRef(surface_); + if (!surface) + die("invalid surface provided"); + int64_t wid = reinterpret_cast<intptr_t>(surface); + int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); + if (result < 0) + ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); +} + +jni_func(void, detachSurface) { + CHECK_MPV_INIT(); + + int64_t wid = 0; + int result = mpv_set_option(g_mpv, "wid", MPV_FORMAT_INT64, &wid); + if (result < 0) + ALOGE("mpv_set_option(wid) returned error %s", mpv_error_string(result)); + + env->DeleteGlobalRef(surface); + surface = NULL; +} diff --git a/app/src/main/jni/thumbnail.cpp b/app/src/main/jni/thumbnail.cpp new file mode 100644 index 00000000..d1156f8e --- /dev/null +++ b/app/src/main/jni/thumbnail.cpp @@ -0,0 +1,133 @@ +#include <stdlib.h> +#include <string> + +#include <jni.h> +#include <android/bitmap.h> +#include <mpv/client.h> + +extern "C" { + #include <libswscale/swscale.h> +}; + +#include "jni_utils.h" +#include "globals.h" +#include "log.h" + +extern "C" { + jni_func(jobject, grabThumbnail, jint dimension); +}; + +static inline mpv_node make_node_str(const char *s) +{ + mpv_node r{}; + r.format = MPV_FORMAT_STRING; + r.u.string = const_cast<char*>(s); + return r; +} + +jni_func(jobject, grabThumbnail, jint dimension) { + CHECK_MPV_INIT(); + + mpv_node result{}; + { + mpv_node c{}, c_args[2]; + mpv_node_list c_array{}; + c_args[0] = make_node_str("screenshot-raw"); + c_args[1] = make_node_str("video"); + c_array.num = 2; + c_array.values = c_args; + c.format = MPV_FORMAT_NODE_ARRAY; + c.u.list = &c_array; + if (mpv_command_node(g_mpv, &c, &result) < 0) { + ALOGE("screenshot-raw command failed"); + return NULL; + } + } + + // extract relevant property data from the node map mpv returns + int w = 0, h = 0, stride = 0; + bool format_ok = false; + struct mpv_byte_array *data = NULL; + do { + if (result.format != MPV_FORMAT_NODE_MAP) + break; + for (int i = 0; i < result.u.list->num; i++) { + std::string key(result.u.list->keys[i]); + const mpv_node *val = &result.u.list->values[i]; + if (key == "w" || key == "h" || key == "stride") { + if (val->format != MPV_FORMAT_INT64) + break; + if (key == "w") + w = val->u.int64; + else if (key == "h") + h = val->u.int64; + else + stride = val->u.int64; + } else if (key == "format") { + if (val->format != MPV_FORMAT_STRING) + break; + format_ok = !strcmp(val->u.string, "bgr0"); + } else if (key == "data") { + if (val->format != MPV_FORMAT_BYTE_ARRAY) + break; + data = val->u.ba; + } + } + } while (0); + if (!w || !h || !stride || !format_ok || !data) { + ALOGE("extracting data failed"); + mpv_free_node_contents(&result); + return NULL; + } + ALOGV("screenshot w:%d h:%d stride:%d", w, h, stride); + + // crop to square + int crop_left = 0, crop_top = 0; + int new_w = w, new_h = h; + if (w > h) { + crop_left = (w - h) / 2; + new_w = h; + } else { + crop_top = (h - w) / 2; + new_h = w; + } + ALOGV("cropped w:%u h:%u", new_w, new_h); + + uint8_t *new_data = reinterpret_cast<uint8_t*>(data->data); + new_data += crop_left * sizeof(uint32_t); // move begin rightwards + new_data += stride * crop_top; // move begin downwards + + // convert & scale to appropriate size + struct SwsContext *ctx = sws_getContext( + new_w, new_h, AV_PIX_FMT_BGR0, + dimension, dimension, AV_PIX_FMT_RGB32, + SWS_BICUBIC, NULL, NULL, NULL); + if (!ctx) { + mpv_free_node_contents(&result); + return NULL; + } + + jintArray arr = env->NewIntArray(dimension * dimension); + jint *scaled = env->GetIntArrayElements(arr, NULL); + + uint8_t *src_p[4] = { new_data }, *dst_p[4] = { (uint8_t*) scaled }; + int src_stride[4] = { stride }, + dst_stride[4] = { (int) sizeof(jint) * dimension }; + sws_scale(ctx, src_p, src_stride, 0, new_h, dst_p, dst_stride); + sws_freeContext(ctx); + + mpv_free_node_contents(&result); // frees data->data + + // create android.graphics.Bitmap + env->ReleaseIntArrayElements(arr, scaled, 0); + + jobject bitmap_config = + env->GetStaticObjectField(android_graphics_Bitmap_Config, android_graphics_Bitmap_Config_ARGB_8888); + jobject bitmap = + env->CallStaticObjectMethod(android_graphics_Bitmap, android_graphics_Bitmap_createBitmap, + arr, dimension, dimension, bitmap_config); + env->DeleteLocalRef(arr); + env->DeleteLocalRef(bitmap_config); + + return bitmap; +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 910853e9..50b097fd 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -6,7 +6,6 @@ option java_multiple_files = true; enum ShowNextUpWhen{ END_OF_PLAYBACK = 0; DURING_CREDITS = 1; - NEXT_UP_NEVER = 2; } enum SkipSegmentBehavior{ @@ -33,36 +32,18 @@ enum MediaExtensionStatus{ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; - PREFER_MPV = 2; - EXTERNAL_PLAYER = 3; } message MpvOptions{ bool enable_hardware_decoding = 1; - bool use_gpu_next = 2; -} - -enum AssPlaybackMode{ - ASS_LIBASS = 0; - ASS_EXO_PLAYER = 1; - ASS_TRANSCODE = 2; } message PlaybackOverrides{ bool ac3_supported = 1; bool downmix_stereo = 2; - bool direct_play_ass = 3 [deprecated = true]; + bool direct_play_ass = 3; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; - bool direct_play_dolby_vision_e_l = 6 [deprecated = true]; - bool decode_av1 = 7; - AssPlaybackMode ass_playback_mode = 8; - int32 max_resolution = 9; - HdrOverrideMode dovi_profile_5_override = 10; - HdrOverrideMode dovi_profile_7_override = 11; - HdrOverrideMode dovi_profile_8_override = 12; - HdrOverrideMode hdr10_override = 13; - HdrOverrideMode hdr10_plus_override = 14; } message PlaybackPreferences { @@ -89,25 +70,12 @@ message PlaybackPreferences { bool one_click_pause = 19; PlayerBackend player_backend = 20; MpvOptions mpv_options = 21; - bool refresh_rate_switching = 22; - 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{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; - bool combine_continue_next = 3 [deprecated = true]; - int32 max_days_next_up = 4; - bool click_to_play = 5; + bool combine_continue_next = 3; } enum ThemeSongVolume { @@ -126,9 +94,6 @@ enum AppThemeColors { ORANGE = 3; BOLD_BLUE = 4; OLED_BLACK = 5; - RED = 6; - BROWN = 7; - reserved 8; } enum EdgeStyle{ @@ -154,100 +119,22 @@ message SubtitlePreferences{ BackgroundStyle background_style = 8; bool font_bold = 9; bool font_italic = 10; - int32 margin = 11; - int32 edge_thickness = 12; - int32 image_subtitle_opacity = 13; - bool use_separate_hdr = 14; -} - -message LiveTvPreferences { - bool show_header = 1; - bool favorite_channels_at_beginning = 2; - bool sort_by_recently_watched = 3; - bool color_code_programs = 4; -} - -enum BackdropStyle{ - BACKDROP_DYNAMIC_COLOR = 0; - BACKDROP_IMAGE_ONLY = 1; - BACKDROP_NONE = 2; -} - -message ScreensaverPreferences{ - int64 start_delay = 1; - int64 duration = 2; - bool animate = 3; - int32 max_age_filter = 4; - bool enabled = 5; - repeated string item_types = 6; - bool show_clock = 7; - bool dim_enabled = 8; - int32 dim_percent = 9; -} - -message SearchPreferences { - bool combined_search_results = 1; - bool show_voice_search_button = 2; -} - -enum DisplayToggle{ - OFFICIAL_RATING = 0; - CRITIC_RATING = 1; - COMMUNITY_RATING = 2; } message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; - map<string, int32> remembered_tabs = 3 [deprecated = true]; + map<string, int32> remembered_tabs = 3; AppThemeColors app_theme_colors = 4; bool nav_drawer_switch_on_focus = 5; bool show_clock = 6; SubtitlePreferences subtitles_preferences = 7; - LiveTvPreferences live_tv_preferences = 8; - BackdropStyle backdrop_style = 9; - SubtitlePreferences hdr_subtitles_preferences = 10; - ScreensaverPreferences screensaver_preference = 11; - bool enable_media_management = 12; - bool show_logos = 13; - SearchPreferences search_preferences = 14; - repeated DisplayToggle display_toggles = 15; -} - -message AdvancedPreferences { - int64 image_disk_cache_size_bytes = 1; -} - -message PhotoPreferences{ - int64 slideshow_duration = 1; - bool slideshow_play_videos = 2; -} - -message MusicPreferences { - bool show_lyrics = 1; - bool show_visualizer = 2; - bool show_album_Art = 3; - bool show_backdrop = 4; -} - -enum HdrOverrideMode{ - HDR_OVERRIDE_AUTO = 0; - HDR_OVERRIDE_ENABLE = 1; - HDR_OVERRIDE_DISABLE = 2; -} - -message ExperimentalPreferences { - bool enabled = 1; - bool video_tunneling_enabled = 2; - bool prefer_ac3_surround = 3; - bool disable_audio_offload = 4; - bool prefer_dolby_vision_over_hdr10_plus = 5; - bool convert_dolby_vision_profile_7 = 6; } message AppPreferences { - string current_server_id = 1 [deprecated = true]; - string current_user_id = 2 [deprecated = true]; + // The currently signed in server and user IDs, mostly for restoring a session + string current_server_id = 1; + string current_user_id = 2; PlaybackPreferences playback_preferences = 3; HomePagePreferences home_page_preferences = 4; @@ -256,10 +143,4 @@ message AppPreferences { bool auto_check_for_updates = 6; string update_url = 7; bool send_crash_reports = 8; - bool debug_logging = 9; - AdvancedPreferences advanced_preferences = 10; - bool sign_in_automatically = 11; - PhotoPreferences photo_preferences = 12; - MusicPreferences music_preferences = 13; - ExperimentalPreferences experimental_preferences = 14; } diff --git a/app/src/main/res/drawable/ic_rating_imdb.xml b/app/src/main/res/drawable/ic_rating_imdb.xml deleted file mode 100644 index 6725d42f..00000000 --- a/app/src/main/res/drawable/ic_rating_imdb.xml +++ /dev/null @@ -1,22 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="575dp" - android:height="289.83dp" - android:viewportWidth="575" - android:viewportHeight="289.83"> - <path - android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" - android:fillColor="#f6c700"/> - <path - android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" - android:fillColor="#000000"/> - <path - android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" - android:fillColor="#000000"/> - <path - android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" - android:fillColor="#000000"/> - <path - android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" - android:fillColor="#000000"/> -</vector> diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml deleted file mode 100644 index 31c0be7d..00000000 --- a/app/src/main/res/drawable/ic_rating_tmdb.xml +++ /dev/null @@ -1,16 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="185.04dp" - android:height="133.4dp" - android:viewportWidth="185.04" - android:viewportHeight="133.4"> - <path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"> - <aapt:attr name="android:fillColor"> - <gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7"> - <item android:offset="0" android:color="#90cea1"/> - <item android:offset="0.56" android:color="#3cbec9"/> - <item android:offset="1" android:color="#00b3e5"/> - </gradient> - </aapt:attr> - </path> -</vector> diff --git a/app/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml b/app/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml deleted file mode 100644 index 5dbfb65e..00000000 --- a/app/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml +++ /dev/null @@ -1,15 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="138.75dp" - android:height="141.25dp" - android:viewportWidth="138.75" - android:viewportHeight="141.25"> - <path - android:pathData="m20.15,40.83c-28.15,27.62 -13.66,61.01 -5.73,71.93 35.25,41.95 92.79,25.34 111.89,-5.91 4.76,-8.2 22.55,-53.47 -23.98,-78.01z" - android:fillColor="#f93208"/> - <path - android:pathData="m39.61,39.26 l4.78,-8.86 28.41,-5.04 11.12,9.21z" - android:fillColor="#f93208"/> - <path - android:pathData="m39.44,8.57 l8.97,-5.28 6.76,15.48c3.79,-6.32 13.79,-16.32 24.94,-4.67 -4.73,1.26 -7.52,3.86 -7.74,8.48 15.15,-4.17 31.34,3.21 33.54,9.09 -10.95,-4.31 -27.69,10.38 -41.77,2.33 0.01,15.05 -12.62,16.64 -19.9,17.08 2.08,-5 5.59,-9.99 1.47,-14.99 -7.62,8.17 -13.87,10.67 -33.17,4.67 4.88,-1.68 14.84,-11.39 24.45,-11.43 -6.78,-2.47 -12.29,-2.09 -17.81,-1.48 2.92,-3.96 12.15,-15.2 28.63,-8.48z" - android:fillColor="#02902e"/> -</vector> diff --git a/app/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml b/app/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml deleted file mode 100644 index 1e3dc568..00000000 --- a/app/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml +++ /dev/null @@ -1,9 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="145dp" - android:height="140dp" - android:viewportWidth="145" - android:viewportHeight="140"> - <path - android:pathData="M47.4,35.34c-13.61,-7.93 -12.32,-25.2 2.1,-31.88 26.12,-6.53 29.12,13.78 22.65,30.41 -6.54,24.11 18.09,23.66 19.92,10.07 3.61,-18.41 19.39,-26.69 31.67,-16.36 12.6,12.14 7.07,36.58 -17.83,34.19 -16.03,-1.54 -19.55,19.58 0.84,21.18 32.23,1.91 42.49,22.17 31.04,35.87 -15.99,15.15 -37.69,-4.44 -45.51,-19.5 -6.8,-9.31 -17.32,0.11 -13.42,6.5 12.98,19.47 2.92,31.23 -10.91,30.62 -13.37,-0.85 -20.96,-9.06 -13.21,-29.15 3.9,-12.48 -8.6,-15.39 -16.57,-5.45 -11.71,19.61 -28.86,13.68 -33.98,4.19 -3.24,-7.62 -2.92,-25.85 24.12,-23.7 16.69,4.14 11.78,-12.56 -0.63,-13.63 -9.24,-0.44 -30.5,-7.3 -22.86,-24.54 7.34,-11.06 24.96,-11.77 33.35,6.29 3.04,4.23 8.36,11.04 18.04,5.03 3.51,-5.2 1.21,-13.9 -8.81,-20.14z" - android:fillColor="#0fc755"/> -</vector> diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml deleted file mode 100644 index fea81f97..00000000 --- a/app/src/main/res/drawable/ic_rt_spilled.xml +++ /dev/null @@ -1,73 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="143.75dp" - android:height="108.75dp" - android:viewportWidth="143.75" - android:viewportHeight="108.75"> - <path - android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" - android:fillColor="#07a23b"/> - <path - android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z" - android:fillColor="#fff"/> - <path - android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z" - android:fillColor="#fff"/> - <path - android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z" - android:fillColor="#fff"/> - <path - android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z" - android:fillColor="#fff"/> - <path - android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" - android:fillColor="#03621e"/> - <path - android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184" - android:fillColor="#fdd600"/> - <path - android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887" - android:fillColor="#fdd600"/> - <path - android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266" - android:fillColor="#fdd600"/> - <path - android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347" - android:fillColor="#fdd600"/> - <path - android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148" - android:fillColor="#fdd600"/> - <path - android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887" - android:fillColor="#fdd600"/> - <path - android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113" - android:fillColor="#fdd600"/> - <path - android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761" - android:fillColor="#fdd600"/> - <path - android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383" - android:fillColor="#fdd600"/> - <path - android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996" - android:fillColor="#fdd600"/> - <path - android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031" - android:fillColor="#fdd600"/> - <path - android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031" - android:fillColor="#fdd600"/> - <path - android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023" - android:fillColor="#fdd600"/> - <path - android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437" - android:fillColor="#fdd600"/> - <path - android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z" - android:fillColor="#fdd600"/> - <path - android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" - android:fillColor="#09a339"/> -</vector> diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml deleted file mode 100644 index e7166b39..00000000 --- a/app/src/main/res/drawable/ic_rt_upright.xml +++ /dev/null @@ -1,52 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="106.25dp" - android:height="140dp" - android:viewportWidth="106.25" - android:viewportHeight="140"> - <path - android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z" - android:fillColor="#fa3106"/> - <path - android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z" - android:fillColor="#fff"/> - <path - android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z" - android:fillColor="#ffd600"/> - <path - android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z" - android:fillColor="#ffd600"/> -</vector> diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml deleted file mode 100644 index 5714801d..00000000 --- a/app/src/main/res/raw/keep.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources xmlns:tools="http://schemas.android.com/tools" - tools:keep="@raw/aboutlibraries" /> diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties deleted file mode 100644 index 467b3efe..00000000 --- a/app/src/main/res/resources.properties +++ /dev/null @@ -1 +0,0 @@ -unqualifiedResLocale=en-US diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml deleted file mode 100644 index 6db20221..00000000 --- a/app/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,193 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">حول</string> - <string name="active_recordings">التسجيلات النشطة</string> - <string name="add_favorite">إضافة للمفضلة</string> - <string name="add_server">إضافة خادم</string> - <string name="add_user">إضافة مستخدم</string> - <string name="audio">الصوت</string> - <string name="birthplace">مكان الميلاد</string> - <string name="bitrate">معدل البت</string> - <string name="born">وُلد</string> - <string name="cancel_recording">إلغاء التسجيل</string> - <string name="cancel_series_recording">إلغاء تسجيل المسلسل</string> - <string name="cancel">إلغاء</string> - <string name="chapters">الفصول</string> - <string name="choose_stream">اختيار %1$s</string> - <string name="clear_image_cache">مسح ذاكرة التخزين المؤقت للصور</string> - <string name="collection">مجموعة</string> - <string name="collections">المجموعات</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="critic_rating">تقييم النقاد</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="disabled">مُعطّل</string> - <string name="discovered_servers">الخوادم المكتشفة</string> - <string name="download_and_update">تنزيل وتحديث</string> - <string name="downloading">تنزيل…</string> - <string name="enabled">مُفعّل</string> - <string name="ends_at">ينتهي عند %1$s</string> - <string name="enter_server_url">أدخل عنوان IP للخادم أو رابط URL</string> - <string name="enter_server_address">أدخل عنوان الخادم</string> - <string name="episodes">الحلقات</string> - <string name="error_loading_collection">خطأ في تحميل المجموعة %1$s</string> - <string name="external_track">خارجي</string> - <string name="favorites">المفضلة</string> - <string name="file_size">الحجم</string> - <string name="forced_track">إجبارية</string> - <string name="genres">الأنواع</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> - <string name="home">الرئيسية</string> - <string name="immediate">فوري</string> - <string name="intro">المقدمة</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">المكتبة</string> - <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_unwatched">تعيين كغير مشاهد</string> - <string name="mark_watched">تعيين كمشاهد</string> - <string name="max_bitrate">الحد الأقصى لمعدل البت</string> - <string name="more_like_this">المزيد من هذا القبيل</string> - <string name="more">المزيد</string> - <plurals name="movies"> - <item quantity="zero">الأفلام</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_servers_found">لم يتم العثور على خوادم</string> - <string name="no_scheduled_recordings">لا توجد تسجيلات مجدولة</string> - <string name="no_update_available">لا يوجد تحديث متاح</string> - <string name="none">لا شيء</string> - <string name="only_forced_subtitles">الترجمات الإجبارية فقط</string> - <string name="outro">خاتمة</string> - <string name="path">المسار</string> - <plurals name="people"> - <item quantity="zero">الأشخاص</item> - <item quantity="other">الأشخاص</item> - </plurals> - <string name="play_count">عدد مرات التشغيل</string> - <string name="play_from_here">تشغيل من هنا</string> - <string name="play">تشغيل</string> - <string name="playback_debug_info">إظهار معلومات التشغيل التفصيلية</string> - <string name="playback_speed">سرعة التشغيل</string> - <string name="playback">التشغيل</string> - <string name="playlist">قائمة التشغيل</string> - <string name="playlists">قوائم التشغيل</string> - <string name="preview">معاينة</string> - <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_recorded">التسجيلات الأخيرة</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="restart">إعادة تشغيل</string> - <string name="resume">استئناف</string> - <string name="save">حفظ</string> - <string name="search_and_download">بحث وتنزيل</string> - <string name="search">بحث</string> - <string name="searching">جارٍ البحث…</string> - <string name="voice_search">البحث الصوتي</string> - <string name="voice_starting">جارٍ البدء</string> - <string name="voice_search_prompt">تحدث للبحث</string> - <string name="processing">جارٍ المعالجة</string> - <string name="press_back_to_cancel">اضغط رجوع للإلغاء</string> - <string name="voice_error_audio">خطأ في تسجيل الصوت</string> - <string name="voice_error_client">خطأ في التعرف على الصوت</string> - <string name="voice_error_permissions">مطلوب إذن الوصول للميكروفون</string> - <string name="voice_error_network">خطأ في الشبكة</string> - <string name="voice_error_network_timeout">انتهت مهلة الشبكة</string> - <string name="voice_error_no_match">لم يتم التعرف على الكلام</string> - <string name="voice_error_busy">التعرف على الصوت مشغول حالياً</string> - <string name="voice_error_server">خطأ في الخادم</string> - <string name="voice_error_speech_timeout">لم يتم اكتشاف صوت</string> - <string name="voice_error_unknown">خطأ غير معروف</string> - <string name="voice_error_start_failed">فشل بدء التعرف على الصوت</string> - <string name="voice_error_timeout">انتهت مهلة التعرف على الصوت</string> - <string name="retry">إعادة المحاولة</string> - <string name="select_server">اختر الخادم</string> - <string name="select_user">اختر المستخدم</string> - <string name="show_debug_info">إظهار معلومات تصحيح الأخطاء</string> - <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="sort_by_date_played">تاريخ التشغيل</string> - <string name="sort_by_date_released">تاريخ الإصدار</string> - <string name="sort_by_name">الاسم</string> - <string name="sort_by_random">عشوائي</string> - <string name="studios">استوديوهات</string> - <string name="submit">إرسال</string> - <string name="suggestions">اقتراحات</string> - <string name="switch_servers">بدّل الخوادم</string> - <string name="version">إصدار</string> - <string name="tv_seasons">مواسم</string> - <string name="tv_season">موسم</string> - <string name="tv_guide">دليل</string> - <string name="watch_live">مشاهدة مباشرة</string> - <string name="background">الخلفية</string> - <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_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 deleted file mode 100644 index d7509408..00000000 --- a/app/src/main/res/values-be/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?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-ca/strings.xml b/app/src/main/res/values-ca/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-ca/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> -</resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml deleted file mode 100644 index d71123be..00000000 --- a/app/src/main/res/values-cs/strings.xml +++ /dev/null @@ -1,586 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">O aplikaci</string> - <string name="active_recordings">Aktivní nahrávání</string> - <string name="add_favorite">Oblíbené</string> - <string name="add_server">Přidat server</string> - <string name="add_user">Přidat uživatele</string> - <string name="audio">Zvuk</string> - <string name="birthplace">Místo narození</string> - <string name="bitrate">Bitrate</string> - <string name="born">Narozen</string> - <string name="cancel_recording">Zrušit nahrávání</string> - <string name="cancel_series_recording">Zrušit nahrávání seriálu</string> - <string name="cancel">Zrušit</string> - <string name="chapters">Kapitoly</string> - <string name="choose_stream">Vybrat %1$s</string> - <string name="clear_image_cache">Vymazat mezipaměť obrázků</string> - <string name="collection">Sbírka</string> - <string name="collections">Kolekce</string> - <string name="community_rating">Hodnocení Komunity</string> - <string name="compose_error_message_title">Došlo k chybě! Stiskněte tlačítko pro odeslání protokolů na server.</string> - <string name="confirm">Potvrdit</string> - <string name="continue_watching">Pokračovat ve sledování</string> - <string name="critic_rating">Hodnocení kritiků</string> - <string name="decimal_seconds">%.2f sekund</string> - <string name="default_track">Výchozí</string> - <string name="delete">Smazat</string> - <string name="died">Zemřel</string> - <string name="directed_by">Režie %1$s</string> - <string name="director">Režisér</string> - <string name="disabled">Vypnuto</string> - <string name="discovered_servers">Nalezené Servery</string> - <string name="downloading">Stahování…</string> - <string name="enabled">Zapnuto</string> - <string name="ends_at">Konec v %1$s</string> - <string name="enter_server_url">Vložte Server IP nebo URL</string> - <string name="enter_server_address">Vložte adresu serveru</string> - <string name="episodes">Epizody</string> - <string name="error_loading_collection">Chyba při načítání kolekce %1$s</string> - <string name="external_track">Externí</string> - <string name="favorites">Oblíbený</string> - <string name="file_size">Velikost</string> - <string name="forced_track">Nucené</string> - <string name="genres">Žánry</string> - <string name="go_to">Přejít do</string> - <string name="hide_controller_timeout">Skrýt ovládací prvky přehrávání</string> - <string name="go_to_series">Přejít na sérii</string> - <string name="hide_debug_info">Skrýt informace o ladění</string> - <string name="hide">Skrýt</string> - <string name="home">Domů</string> - <string name="intro">Úvod</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Knihovna</string> - <string name="license_info">Informace o licenci</string> - <string name="live_tv">Živá TV</string> - <string name="loading">Načítání…</string> - <string name="mark_entire_series_as_played">Označit celou sérii jako zhlédnutou?</string> - <string name="mark_entire_series_as_unplayed">Označit celou sérii jako nezhlédnuté?</string> - <string name="mark_unwatched">Označit jako nezhlédnuté</string> - <string name="mark_watched">Označit jako zhlédnuté</string> - <string name="max_bitrate">Maximální bitrate</string> - <string name="more_like_this">Více podobných</string> - <string name="more">Více</string> - <plurals name="movies"> - <item quantity="one">Filmy</item> - <item quantity="other">Filmy</item> - </plurals> - <string name="name">Jméno</string> - <string name="next_up">Další v pořadí</string> - <string name="no_data">Žádná data</string> - <string name="no_results">Žádné výsledky</string> - <string name="no_servers_found">Nenalezeny žádné servery</string> - <string name="no_scheduled_recordings">Žádné naplánované nahrávání</string> - <string name="no_update_available">Žádné aktualizace nejsou k dispozici</string> - <string name="none">Žádný</string> - <string name="only_forced_subtitles">Pouze vynucené titulky</string> - <string name="outro">Závěr</string> - <string name="path">Cesta</string> - <plurals name="people"> - <item quantity="one">Lidé</item> - <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> - <string name="play">Přehrát</string> - <string name="playback_debug_info">Zobrazit informace o ladění přehrávání</string> - <string name="playback_speed">Rychlost přehrávání</string> - <string name="playback">Přehrávání</string> - <string name="playlist">Seznam</string> - <string name="playlists">Seznamy</string> - <string name="preview">Náhled</string> - <string name="profile_specific_settings">Nastavení uživatelského profilu</string> - <string name="queue">Fronta</string> - <string name="recap">Shrnout</string> - <string name="recently_added_in">Nedávno přidáno v %1$s</string> - <string name="recently_added">Nedávno přidáno</string> - <string name="recently_recorded">Nedávno nahráno</string> - <string name="recently_released">Nedávno vydané</string> - <string name="recommended">Doporučeno</string> - <string name="record_program">Nahrát Program</string> - <string name="record_series">Nahrát Seriál</string> - <string name="remove_favorite">Odebrat z oblíbených</string> - <string name="restart">Restart</string> - <string name="resume">Pokračovat</string> - <string name="save">Uložit</string> - <string name="search">Hledat</string> - <string name="searching">Vyhledávání…</string> - <string name="voice_search">Hlasové vyhledávání</string> - <string name="voice_starting">Začátek</string> - <string name="voice_search_prompt">Hledat hlasem</string> - <string name="processing">Zpracování</string> - <string name="press_back_to_cancel">Zrušíte stisknutím tlačítka zpět</string> - <string name="voice_error_audio">Chyba nahrávání zvuku</string> - <string name="voice_error_client">Chyba rozpoznávání hlasu</string> - <string name="voice_error_permissions">Vyžadováno oprávnění k přístupu k mikrofonu</string> - <string name="voice_error_network">Chyba sítě</string> - <string name="voice_error_network_timeout">Časový limit sítě</string> - <string name="voice_error_no_match">Řeč nerozpoznaná</string> - <string name="voice_error_busy">Rozpoznávání hlasu je zaneprázdněno</string> - <string name="voice_error_server">Chyba serveru</string> - <string name="voice_error_speech_timeout">Nebyla detekována žádná řeč</string> - <string name="voice_error_unknown">Neznámá chyba</string> - <string name="voice_error_start_failed">Nepodařilo se spustit rozpoznávání hlasu</string> - <string name="voice_error_timeout">Časový limit pro rozpoznávání hlasu vypršel</string> - <string name="retry">Zkusit znovu</string> - <string name="select_server">Vybrat server</string> - <string name="select_user">Vybrat Uživatele</string> - <string name="show_debug_info">Zobrazit informace o ladění</string> - <string name="show_next_up_when">Zobrazit další</string> - <string name="show">Ukázat</string> - <string name="shuffle">Náhodně</string> - <string name="skip">Přeskočit</string> - <string name="sort_by_date_added">Datum přidání</string> - <string name="sort_by_date_episode_added">Datum přidání epizody</string> - <string name="sort_by_date_played">Datum přehraní</string> - <string name="sort_by_date_released">Datum vydání</string> - <string name="sort_by_name">Jméno</string> - <string name="sort_by_random">Náhodný</string> - <string name="studios">Studia</string> - <string name="submit">Odeslat</string> - <string name="subtitle_download_too_long">Stahování trvá dlouho, možná budete muset restartovat přehrávání</string> - <string name="subtitle">Podnázev</string> - <string name="subtitles">Titulky</string> - <string name="suggestions">Návrhy</string> - <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> - <plurals name="trailers"> - <item quantity="one">Upoutávky</item> - <item quantity="other">Upoutávky</item> - </plurals> - <string name="tv_dvr_schedule">Harmonogram nahrávání</string> - <string name="tv_guide">Televizní program</string> - <string name="tv_season">Sezóna</string> - <string name="tv_seasons">Sezóny</string> - <plurals name="tv_shows"> - <item quantity="one">Seriály</item> - <item quantity="other">Seriály</item> - </plurals> - <string name="ui_interface">Prostředí</string> - <string name="unknown">Neznámý</string> - <string name="updates">Aktualizace</string> - <string name="version">Verze</string> - <string name="video_scale">Měřítko videa</string> - <string name="video">Video</string> - <string name="videos">Videa</string> - <string name="watch_live">Sledujte živě</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> - <string name="aired_episode_order">Pořadí odvysílaných epizod</string> - <string name="create_playlist">Vytvořit nový playlist</string> - <string name="add_to_playlist">Přidat do playlistu</string> - <string name="one_click_pause">Pozastavení jedním kliknutím</string> - <string name="one_click_pause_summary_on">Stisknutím středu D-Padu pozastavíte/přehrajete</string> - <string name="italic_font">Kurzíva</string> - <string name="font">Font</string> - <string name="background">Pozadí</string> - <string name="success">Úspěch</string> - <string name="official_rating">Rodičovské hodnocení</string> - <string name="extras_title">Doplňky</string> - <plurals name="other_extras"> - <item quantity="one">Jiný</item> - <item quantity="other">Jiný</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">V zákulisí</item> - <item quantity="other">V zákulisí</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Znělky</item> - <item quantity="other">Znělky</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Tématická videa</item> - <item quantity="other">Tématická videa</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klipy</item> - <item quantity="other">Klipy</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Vymazané scény</item> - <item quantity="other">Vymazané scény</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Rozhovory</item> - <item quantity="other">Rozhovory</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Scény</item> - <item quantity="other">Scény</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Krátkometrážní filmy</item> - <item quantity="other">Krátkometrážní filmy</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d hodina</item> - <item quantity="few">%d hodin</item> - <item quantity="many">%d hodin</item> - <item quantity="other">%d hodin</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d položka</item> - <item quantity="few">%d položky</item> - <item quantity="many">%d položek</item> - <item quantity="other">%d položek</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekunda</item> - <item quantity="few">%d sekundy</item> - <item quantity="many">%d sekund</item> - <item quantity="other">%d sekund</item> - </plurals> - <string name="ac3_supported">Zařízení podporuje AC3/Dolby Digital</string> - <string name="advanced_settings">Pokročilá Nastavení</string> - <string name="advanced_ui">Pokročilé UI</string> - <string name="app_theme">Vzhled Aplikace</string> - <string name="auto_check_for_updates">Automaticky kontrolovat aktualizace</string> - <string name="auto_play_next_delay">Zpoždění před dalším přehráním</string> - <string name="auto_play_next">Další přehrát automaticky</string> - <string name="check_for_updates">Zkontrolovat aktualizace</string> - <string name="combine_continue_next_summary">Platí pouze pro Seriály</string> - <string name="downmix_stereo">Vždy downmixovat do stereo formátu</string> - <string name="ffmpeg_extension_pref">Použijte modul dekodéru FFmpeg</string> - <string name="global_content_scale">Výchozí měřítko obsahu</string> - <string name="install_update">Nainstalovat aktualizaci</string> - <string name="installed_version">Nainstalovaná verze</string> - <string name="max_homepage_items">Maximální počet položek v řádcích domovské stránky</string> - <string name="nav_drawer_pins_summary">Vyberte výchozí položky, které se mají zobrazit, ostatní budou skryty</string> - <string name="nav_drawer_pins">Přizpůsobení položek navigačního panelu</string> - <string name="nav_drawer_switch_on_focus_summary_off">Kliknutím přepnete stránky</string> - <string name="nav_drawer_switch_on_focus">Přepnout stránky navigačního panelu při zaostření</string> - <string name="pass_out_protection">Ochrana proti výpadku</string> - <string name="play_theme_music">Přehrát tematickou hudbu</string> - <string name="playback_overrides">Přepsání přehrávání</string> - <string name="remember_selected_tab">Zapamatovat vybrané karty</string> - <string name="rewatch_next_up">Povolit opakované sledování v dalším videu</string> - <string name="seek_bar_steps">Kroky Seek baru</string> - <string name="send_app_logs_summary">Užitečné pro ladění</string> - <string name="send_app_logs">Odeslat protokoly aplikací na aktuální server</string> - <string name="send_crash_reports_summary">Pokusím se odeslat na poslední připojený server</string> - <string name="send_crash_reports">Odesílat zprávy o selhání</string> - <string name="settings">Nastavení</string> - <string name="skip_back_on_resume_preference">Přeskočit zpět při obnovení přehrávání</string> - <string name="skip_back_preference">Přeskočit zpět</string> - <string name="skip_commercials_behavior">Přeskakovat reklamy</string> - <string name="skip_forward_preference">Přeskočit vpřed</string> - <string name="skip_intro_behavior">Chování přeskočení úvodu</string> - <string name="skip_outro_behavior">Přeskočit chování konce</string> - <string name="skip_previews_behavior">Chování přeskočení náhledů</string> - <string name="update_available">Aktualizace k dispozici</string> - <string name="update_url_summary">URL adresa používaná ke kontrole aktualizací aplikace</string> - <string name="update_url">Aktualizovat URL</string> - <string name="font_size">Velikost písma</string> - <string name="font_color">Barva písma</string> - <string name="font_opacity">Průhlednost písma</string> - <string name="edge_style">Styl okraje</string> - <string name="edge_color">Barva okraje</string> - <string name="background_opacity">Průhlednost pozadí</string> - <string name="background_style">Styl pozadí</string> - <string name="subtitle_style">Styl titulků</string> - <string name="background_color">Barva pozadí</string> - <string name="reset">Reset</string> - <string name="bold_font">Tučné písmo</string> - <string name="mpv_hardware_decoding">MPV: Použití hardwarového dekódování</string> - <string name="disable_if_crash">Zakažte, pokud dojde k pádům</string> - <string name="mpv_options">Možnosti MPV</string> - <string name="exoplayer_options">Možnosti ExoPlayeru</string> - <string name="skip_segment_unknown">Přeskočit segment</string> - <string name="skip_segment_commercial">Přeskočit reklamy</string> - <string name="skip_segment_preview">Přeskočit náhled</string> - <string name="skip_segment_recap">Přeskočit shrnutí</string> - <string name="skip_recap_behavior">Chování přeskočení shrnutí</string> - <string name="skip_segment_outro">Přeskočit konec</string> - <string name="skip_segment_intro">Přeskočit úvod</string> - <string name="played">Přehráno</string> - <string name="filter">Filtr</string> - <string name="year">Rok</string> - <string name="decade">Desetiletí</string> - <string name="remove">Odstranit</string> - <string name="dolby_vision">DolbyVision</string> - <string name="dolby_atmos">Atmos</string> - <string name="mpv_use_gpu_next">MPV: Použití gpu-next</string> - <string name="mpv_conf">Upravit mpv.conf</string> - <string name="discard_change">Zahodit změny?</string> - <string name="subtitle_margin">Okraj</string> - <string name="verbose_logging">Podrobné protokolování</string> - <string name="enter_pin">Zadejte PIN</string> - <string name="sign_in_auto">Automatické přihlášení</string> - <string name="use_server_credentials">Přihlášení přes server</string> - <string name="press_enter_to_confirm">Stiskněte středové tlačítko pro potvrzení</string> - <string name="require_pin_code">Vyžadovat PIN pro profil</string> - <string name="confirm_pin">Potvrdit PIN</string> - <string name="incorrect">Nesprávný</string> - <string name="pin_too_short">PIN musí mít 4 znaky nebo více</string> - <string name="will_remove_pin">PIN bude odstraněn</string> - <string name="image_cache_size">Velikost mezipaměti (MB)</string> - <string name="view_options">Možnosti zobrazení</string> - <string name="columns">Sloupce</string> - <string name="spacing">Mezery</string> - <string name="aspect_ratio">Poměr stran</string> - <string name="show_details">Zobrazit podrobnosti</string> - <string name="image_type">Typ obrázku</string> - <string name="guest_stars">Hostující hvězdy</string> - <string name="edge_size">Velikost okraje</string> - <string name="refresh_rate_switching">Přepínání obnovovací frekvence</string> - <string name="automatic">Automaticky</string> - <string name="username_or_password">Použijte uživatelské jméno/heslo</string> - <string name="login">Přihlášení</string> - <string name="general">Obecné</string> - <string name="container">Kontejner</string> - <string name="title">Titul</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Úroveň</string> - <string name="resolution">Rozlišení</string> - <string name="framerate">Snímková frekvence</string> - <string name="bit_depth">Bitová hloubka</string> - <string name="video_range">Rozsah videa</string> - <string name="video_range_type">Typ rozsahu videa</string> - <string name="color_space">Barevný prostor</string> - <string name="color_transfer">Přenos barev</string> - <string name="color_primaries">Základní barvy</string> - <string name="pixel_format">Formát pixelů</string> - <string name="ref_frames">Referenční snímky</string> - <string name="nal">NAL</string> - <string name="language">Jazyk</string> - <string name="layout">Rozložení</string> - <string name="channels">Kanály</string> - <string name="sample_rate">Vzorkovací frekvence</string> - <string name="avc">AVC</string> - <string name="yes">Ano</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">Zobrazit názvy</string> - <string name="live_tv_repeat">Opakovat</string> - <string name="favorite_channels_at_beginning">Zobrazit oblíbené kanály jako první</string> - <string name="sort_channels_recently_watched">Řadit kanály podle naposledy sledovaných</string> - <string name="subtitle_delay">Zpoždění titulků</string> - <string name="backdrop_display">Styl prospektu</string> - <string name="resolution_switching">Přepínání rozlišení</string> - <string name="local">Místní</string> - <string name="play_trailer">Přehrát upoutávku</string> - <string name="no_trailers">Žádné upoutávky</string> - <string name="clear_track_choices">Vyčistit výběr skladeb</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">True HD</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="force_dovi_profile_7_summary">Ignoruj kontrolu kompatibility zařízení</string> - <string name="discover">Najít</string> - <string name="request">Žádost</string> - <string name="pending">Čeká na vyřízení</string> - <string name="seerr_integration">Seerr integrace</string> - <string name="remove_seerr_server">Odstranit Seerr Server</string> - <string name="seerr_server_added">Seerr server přidán</string> - <string name="password">Heslo</string> - <string name="username">Uživatelské jméno</string> - <string name="url">URL</string> - <string name="upcoming_movies">Nadcházející filmy</string> - <string name="trending">Trendy</string> - <string name="upcoming_tv">Nadcházející TV pořady</string> - <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">Download & Update</string> - <string name="immediate">Bezprostředně</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="other">Vzorky</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Krátká videa</item> - <item quantity="other">Krátká videa</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s Stažen</item> - <item quantity="few">%s Staženo</item> - <item quantity="many">%s Stažení</item> - <item quantity="other">%s Stažení</item> - </plurals> - <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">Cast & Crew</string> - <string name="anamorphic">Anamorfní</string> - <string name="interlaced">Prokládané</string> - <string name="color_code_programs">Color code programs</string> - <string name="upgrade_mpv_toast">MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení.</string> - <string name="hdr_subtitle_style">Styl HDR titulků</string> - <string name="image_subtitle_opacity">Průhlednost obrázku titulků</string> - <string name="brightness">Jas</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Sytost</string> - <string name="hue">Odstín</string> - <string name="red">Červená</string> - <string name="green">Zelená</string> - <string name="blue">Modrá</string> - <string name="blur">Rozostření</string> - <string name="save_for_album">Uložit do alba</string> - <string name="play_slideshow">Přehrát prezentaci</string> - <string name="stop_slideshow">Zastavit prezentaci</string> - <string name="slideshow_at_beginning">Na začátek</string> - <string name="no_more_images">Žádné další fotky</string> - <string name="rotate_left">Otočit vlevo</string> - <string name="rotate_right">Otočit vpravo</string> - <string name="zoom_in">Přiblížit</string> - <string name="zoom_out">Oddálit</string> - <string name="slideshow_duration">Trvání prezentace</string> - <string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string> - <string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string> - <plurals name="days"> - <item quantity="one">%s den</item> - <item quantity="few">%s dny</item> - <item quantity="many">%s dní</item> - <item quantity="other">%s dní</item> - </plurals> - <string name="no_limit">Bez omezení</string> - <string name="max_days_next_up">Max. počet dnů v Dalších v pořadí</string> - <string name="quick_connect">Rychlé připojení</string> - <string name="quick_connect_summary">Autorizovat jiné zařízení k přihlášení k vašemu účtu</string> - <string name="quick_connect_code">Zadejte kód Rychlého připojení</string> - <string name="quick_connect_code_error">Kód musí mít 6 číslic</string> - <string name="quick_connect_success">Zařízení úspěšně autorizováno</string> - <string name="add_row">Přidat řádek</string> - <string name="genres_in">Žánry v %1$s</string> - <string name="recently_released_in">Nedávno vydáno v %1$s</string> - <string name="height">Výška</string> - <string name="apply_all_rows">Použít na všechny řádky</string> - <string name="customize_home">Přizpůsobit domovskou stránku</string> - <string name="home_rows">Řádky na domovské stránce</string> - <string name="load_from_server">Načíst ze serverového uživatelského profilu</string> - <string name="save_to_server">Uložit do serverového uživatelského profilu</string> - <string name="load_from_web_client">Načíst z webového klienta</string> - <string name="use_series">Použít obrázek seriálu</string> - <string name="add_row_for">Přidat řádek pro %1$s</string> - <string name="overwrite_server_settings">Přepsat nastavení na serveru?</string> - <string name="overwrite_local_settings">Přepsat místní nastavení?</string> - <string name="for_episodes">Pro epizody</string> - <string name="suggestions_for">Návrhy na %1$s</string> - <string name="increase_all_cards_size">Zvětšit velikost všech karet</string> - <string name="decrease_all_cards_size">Zmenšit velikost všech karet</string> - <string name="use_thumb_images">Použít obrázky miniatur</string> - <string name="settings_saved">Nastavení uložena</string> - <string name="display_presets">Zobrazit předvolby</string> - <string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string> - <string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string> - <string name="favorite_items_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> - <string name="content_scale_fill_width">Vyplnit šířku</string> - <string name="content_scale_fill_height">Vyplnit výšku</string> - <string name="display_preset_default">Výchozí předvolba Wolfskin</string> - <string name="display_preset_compact">Wolfskin kompaktní</string> - <string name="display_preset_series_thumb">Obrázky náhledů seriálů</string> - <string name="display_preset_episode_thumbnails">Obrázky náhledů epizod</string> - <string name="volume_lowest">Nejnižší</string> - <string name="volume_low">Nízká</string> - <string name="volume_medium">Střední</string> - <string name="volume_high">Vysoká</string> - <string name="volume_full">Plná</string> - <string name="purple">Fialová</string> - <string name="orange">Oranžová</string> - <string name="bold_blue">Odvážná modrá</string> - <string name="black">Černá</string> - <string name="skip_ignore">Ignorovat</string> - <string name="skip_automatically">Automaticky přeskočit</string> - <string name="skip_ask">Zeptat se</string> - <string name="ffmpeg_fallback">Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér</string> - <string name="ffmpeg_prefer">Preferovat FFmpeg nad vestavěnými dekodéry</string> - <string name="ffmpeg_never">Nikdy nepoužívat dekodéry FFmpeg</string> - <string name="next_up_playback_end">Na konci přehrávání</string> - <string name="next_up_outro">Během titulků</string> - <string name="white">Bílá</string> - <string name="light_gray">Světle šedá</string> - <string name="dark_gray">Tmavě šedá</string> - <string name="yellow">Žlutá</string> - <string name="cyan">Azurová</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Okrajová linka</string> - <string name="subtitle_edge_shadow">Stín</string> - <string name="background_style_wrap">Zabalené</string> - <string name="background_style_boxed">Krabicové</string> - <string name="prefer_mpv">Preferovat MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Použít ExoPlayer pro přehrávání HDR obsahu</string> - <string name="aspect_ratios_poster">Plakát (2:3)</string> - <string name="aspect_ratios_16_9">16:9</string> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Čtverec (1:1)</string> - <string name="image_type_primary">Primární</string> - <string name="image_type_thumb">Náhled</string> - <string name="backdrop_style_dynamic">Obrázek s dynamickou barvou</string> - <string name="backdrop_style_image">Pouze obrázek</string> - <string name="enter_url_api_key">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">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 deleted file mode 100644 index a6efeb91..00000000 --- a/app/src/main/res/values-da/strings.xml +++ /dev/null @@ -1,712 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Om</string> - <string name="active_recordings">Aktive optagelser</string> - <string name="add_favorite">Favorit</string> - <string name="add_server">Tilføj server</string> - <string name="add_user">Tilføj bruger</string> - <string name="audio">Lyd</string> - <string name="birthplace">Fødested</string> - <string name="bitrate">Bitrate</string> - <string name="born">Født</string> - <string name="cancel_recording">Annullér optagelse</string> - <string name="cancel_series_recording">Annullér serieoptagelse</string> - <string name="cancel">Annullér</string> - <string name="chapters">Kapitler</string> - <string name="choose_stream">Vælg %1$s</string> - <string name="clear_image_cache">Ryd billedcache</string> - <string name="collection">Samling</string> - <string name="collections">Samlinger</string> - <string name="community_rating">Brugerbedømmelse</string> - <string name="compose_error_message_title">Der opstod en fejl! Tryk på knappen for at sende logfiler til din server.</string> - <string name="confirm">Bekræft</string> - <string name="continue_watching">Fortsæt med at se</string> - <string name="critic_rating">Kritikerbedømmelse</string> - <string name="decimal_seconds">%.2f sekunder</string> - <string name="default_track">Standard</string> - <string name="delete">Slet</string> - <string name="died">Død</string> - <string name="directed_by">Instrueret af %1$s</string> - <string name="director">Instruktør</string> - <string name="disabled">Deaktiveret</string> - <string name="discovered_servers">Opdagede servere</string> - <string name="download_and_update">Download og opdater</string> - <string name="downloading">Downloader…</string> - <string name="enabled">Aktiveret</string> - <string name="ends_at">Slutter %1$s</string> - <string name="enter_server_url">Indtast server-IP eller URL</string> - <string name="enter_server_address">Indtast serveradresse</string> - <string name="episodes">Episoder</string> - <string name="error_loading_collection">Fejl ved indlæsning af samling %1$s</string> - <string name="external_track">Ekstern</string> - <string name="favorites">Favoritter</string> - <string name="file_size">Størrelse</string> - <string name="forced_track">Tvunget</string> - <string name="genres">Genrer</string> - <string name="go_to_series">Gå til serien</string> - <string name="go_to">Gå til</string> - <string name="hide_controller_timeout">Skjul afspilningskontroller</string> - <string name="hide_debug_info">Skjul fejlfindingsoplysninger</string> - <string name="hide">Skjul</string> - <string name="home">Hjem</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 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> - <string name="more_like_this">Mere som dette</string> - <string name="more">Mere</string> - <plurals name="movies"> - <item quantity="one">Film</item> - <item quantity="other">Film</item> - </plurals> - <string name="name">Navn</string> - <string name="next_up">Næste</string> - <string name="no_data">Ingen data</string> - <string name="no_results">Ingen resultater</string> - <string name="no_servers_found">Ingen servere fundet</string> - <string name="no_scheduled_recordings">Ingen planlagte optagelser</string> - <string name="no_update_available">Ingen opdatering tilgængelig</string> - <string name="none">Ingen</string> - <string name="only_forced_subtitles">Kun tvungne undertekster</string> - <string name="outro">Outro</string> - <string name="path">Filsti</string> - <plurals name="people"> - <item quantity="one">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 fejlfindingsoplysninger for afspilning</string> - <string name="playback_speed">Afspilningshastighed</string> - <string name="playback">Afspilning</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> - <string name="recap">Opsummering</string> - <string name="recently_added_in">Nyligt tilføjet i %1$s</string> - <string name="recently_added">Nyligt tilføjet</string> - <string name="recently_recorded">Nyligt optaget</string> - <string name="recently_released">Nyligt udgivet</string> - <string name="recommended">Anbefalet</string> - <string name="record_program">Optag program</string> - <string name="record_series">Optag serie</string> - <string name="remove_favorite">Fjern fra favoritter</string> - <string name="restart">Genstart</string> - <string name="resume">Fortsæt</string> - <string name="save">Gem</string> - <string name="search_and_download">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">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> - <string name="voice_error_permissions">Mikrofontilladelse kræves</string> - <string name="voice_error_network">Netværksfejl</string> - <string name="voice_error_network_timeout">Netværkstimeout</string> - <string name="voice_error_no_match">Ingen tale genkendt</string> - <string name="voice_error_busy">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> - <string name="voice_error_start_failed">Kunne ikke starte stemmegenkendelse</string> - <string name="voice_error_timeout">Tidsgrænsen for stemmegenkendelse er overskredet</string> - <string name="retry">Prøv igen</string> - <string name="select_server">Vælg server</string> - <string name="select_user">Vælg bruger</string> - <string name="show_debug_info">Vis fejlfindingsoplysninger</string> - <string name="show_next_up_when">Vis Næste</string> - <string name="show">Vis</string> - <string name="shuffle">Bland</string> - <string name="skip">Spring over</string> - <string name="sort_by_date_added">Dato tilføjet</string> - <string name="sort_by_date_episode_added">Dato 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> - <string name="sort_by_random">Tilfældig</string> - <string name="studios">Studier</string> - <string name="submit">Indsend</string> - <string name="subtitle_download_too_long">Downloaden tager lang tid, du skal muligvis genstarte afspilningen</string> - <string name="subtitle">Undertekst</string> - <string name="subtitles">Undertekster</string> - <string name="suggestions">Forslag</string> - <string name="switch_servers">Skift server</string> - <string name="switch_user">Skift</string> - <string name="top_unwatched">Bedst bedømte usete</string> - <plurals name="trailers"> - <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-serie</item> - <item quantity="other">TV-serier</item> - </plurals> - <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 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">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> - <string name="background">Baggrund</string> - <string name="success">Succes</string> - <string name="official_rating">Aldersgrænse</string> - <string name="runtime_sort">Spilletid</string> - <string name="extras_title">Ekstra</string> - <plurals name="other_extras"> - <item quantity="one">Andet</item> - <item quantity="other">Andet</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Bag scenen</item> - <item quantity="other">Bag scenerne</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Temasang</item> - <item quantity="other">Temasange</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Temavideo</item> - <item quantity="other">Temavideoer</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klip</item> - <item quantity="other">Klip</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Slettet scene</item> - <item quantity="other">Slettede scener</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Interview</item> - <item quantity="other">Interviews</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Scene</item> - <item quantity="other">Scener</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Smagsprøve</item> - <item quantity="other">Smagsprøver</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Featurette</item> - <item quantity="other">Featurettes</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Kort</item> - <item quantity="other">Kort</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s download</item> - <item quantity="other">%s downloads</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d time</item> - <item quantity="other">%d timer</item> - </plurals> - <plurals name="days"> - <item quantity="one">%s dag</item> - <item quantity="other">%s dage</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d element</item> - <item quantity="other">%d elementer</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekund</item> - <item quantity="other">%d sekunder</item> - </plurals> - <string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string> - <string name="advanced_settings">Avancerede indstillinger</string> - <string name="advanced_ui">Avanceret 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 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 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 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">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">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">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 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">Hvordan reklamer springes over</string> - <string name="skip_forward_preference">Spring fremad</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, der bruges til at søge efter appopdateringer</string> - <string name="update_url">URL til opdatering</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">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">Afspilningsmotor</string> - <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</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> - <string name="decade">Årti</string> - <string name="remove">Fjern</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Brug gpu-next</string> - <string name="mpv_conf">Rediger mpv.conf</string> - <string name="discard_change">Kassér ændringer?</string> - <string name="subtitle_margin">Margen</string> - <string name="verbose_logging">Detaljeret logning</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 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-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">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[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 og adgangskode</string> - <string name="login">Log ind</string> - <string name="general">Generelt</string> - <string name="container">Container</string> - <string name="title">Titel</string> - <string name="codec">Codec</string> - <string name="profile">Profil</string> - <string name="level">Niveau</string> - <string name="resolution">Opløsning</string> - <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Sammenflettet</string> - <string name="framerate">Billedfrekvens</string> - <string name="bit_depth">Bitdybde</string> - <string name="video_range">Videoområde</string> - <string name="video_range_type">Type af videoområde</string> - <string name="color_space">Farverum</string> - <string name="color_transfer">Farveoverførsel</string> - <string name="color_primaries">Primære farver</string> - <string name="pixel_format">Pixel-format</string> - <string name="ref_frames">Referencerammer</string> - <string name="nal">NAL</string> - <string name="language">Sprog</string> - <string name="layout">Layout</string> - <string name="channels">Kanaler</string> - <string name="sample_rate">Samplingsfrekvens</string> - <string name="avc">AVC</string> - <string name="yes">Ja</string> - <string name="no">Nej</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Vis titler</string> - <string name="live_tv_repeat">Gentag</string> - <string name="favorite_channels_at_beginning">Vis favoritkanaler først</string> - <string name="sort_channels_recently_watched">Sorter kanaler efter senest sete</string> - <string name="color_code_programs">Farvekod programmer</string> - <string name="subtitle_delay">Undertekstforsinkelse</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 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> - <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">Afspil Dolby Vision-profil 7 direkte</string> - <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</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="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 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">Ø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> - <string name="contrast">Kontrast</string> - <string name="saturation">Mætning</string> - <string name="hue">Farvetone</string> - <string name="red">Rød</string> - <string name="green">Grøn</string> - <string name="blue">Blå</string> - <string name="blur">Sløring</string> - <string name="save_for_album">Gem 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> - <string name="no_more_images">Ikke flere billeder</string> - <string name="rotate_left">Roter til venstre</string> - <string name="rotate_right">Roter til højre</string> - <string name="zoom_in">Zoom ind</string> - <string name="zoom_out">Zoom ud</string> - <string name="slideshow_duration">Diasshowvarighed</string> - <string name="play_videos_during_slideshow">Afspil videoer under diasshow</string> - <string name="send_media_info_log_to_server">Send medieinformationslog til serveren</string> - <string name="no_limit">Ingen grænse</string> - <string name="max_days_next_up">Maks. antal dage i Næste</string> - <string name="add_row">Tilføj række</string> - <string name="genres_in">Genrer i %1$s</string> - <string name="recently_released_in">Nyligt 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 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> - <string name="overwrite_server_settings">Overskriv indstillinger på serveren?</string> - <string name="overwrite_local_settings">Overskriv lokale indstillinger?</string> - <string name="for_episodes">For episoder</string> - <string name="suggestions_for">Forslag til %1$s</string> - <string name="increase_all_cards_size">Øg størrelsen på alle kort</string> - <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> - <string name="use_thumb_images">Brug miniaturebilleder</string> - <string name="settings_saved">Indstillinger gemt</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">Wolfskin-standard</string> - <string name="display_preset_compact">Wolfskin 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">Wolfskin 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 7170a274..137efa0b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,7 +2,7 @@ <resources> <string name="about">Über diese App</string> <string name="active_recordings">Aktive Aufnahmen</string> - <string name="add_favorite">Zu Favoriten hinzufügen</string> + <string name="add_favorite">Favoriten</string> <string name="add_server">Server hinzufügen</string> <string name="add_user">Benutzer hinzufügen</string> <string name="audio">Ton</string> @@ -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">Serienaufnahme abbrechen</string> + <string name="cancel_series_recording">Serien Aufnahme abbrechen</string> <string name="cancel">Abbrechen</string> <string name="chapters">Kapitel</string> <string name="collection">Sammlung</string> @@ -19,20 +19,20 @@ <string name="confirm">Bestätigen</string> <string name="continue_watching">Weiterschauen</string> <string name="critic_rating">Kritiken</string> - <string name="decimal_seconds">%.2f Sekunden</string> + <string name="decimal_seconds">%.1f Sekunden</string> <string name="default_track">Standard</string> <string name="delete">Löschen</string> <string name="died">Gestorben</string> <string name="director">Regisseur</string> <string name="more">Mehr</string> - <string name="movies_title">Filme</string> + <string name="movies">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_title">Personen</string> + <string name="people">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,26 +46,24 @@ <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">Trailer</item> + <item quantity="other"/> </plurals> <string name="tv_season">Staffel</string> <string name="tv_seasons">Staffeln</string> - <string name="tv_shows_title">Serien</string> + <string name="tv_shows">Serien</string> <string name="ui_interface">Oberfläche</string> <string name="unknown">Unbekannt</string> - <string name="updates">Aktualisierungen</string> + <string name="updates">Updates</string> <string name="version">Version</string> <string name="watch_live">Live ansehen</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> + <string name="years_old">%1$d Jahre alt</string> + <string name="extras">Extras</string> <plurals name="scenes"> - <item quantity="one">Szene</item> - <item quantity="other">Szenen</item> + <item quantity="one">Szenen</item> + <item quantity="other"/> </plurals> <plurals name="downloads"> <item quantity="one">%s Download</item> @@ -83,16 +81,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="compose_error_message_title">Ein Fehler ist aufgetreten! Drücke die Taste um Logs an deinen Server zu senden.</string> + <string name="choose_stream">Wählen %1$s</string> + <string name="clear_image_cache">Bilder Cache leeren</string> + <string name="community_rating">Community Bewertung</string> + <string name="compose_error_message_title">Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden.</string> <string name="directed_by">Regie von %1$s</string> - <string name="disabled">Deaktiviert</string> + <string name="disabled">Deaktivert</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 eingeben</string> + <string name="enter_server_url">Serveradresse oder URL inkluse Port 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> @@ -103,13 +101,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">Lizenzinformationen</string> + <string name="license_info">Lizenz Informationen</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> @@ -120,35 +118,35 @@ <string name="next_up">Als nächstes</string> <string name="none">Keine</string> <string name="play_from_here">Von hier wiedergeben</string> - <string name="play">Abspielen</string> - <string name="playback_speed">Wiedergabegeschwindigkeit</string> + <string name="play">Ansehen</string> + <string name="playback_speed">Wiedergabe geschwindigkeit</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> + <string name="recently_released">Zuletzt herausgebracht</string> <string name="record_program">Programm aufnehmen</string> <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="shuffle">Zufall</string> <string name="skip">Überspringen</string> - <string name="sort_by_date_added">Hinzugefügt am</string> - <string name="sort_by_date_episode_added">Episode hinzugefügt am</string> - <string name="sort_by_date_played">Wiedergegeben am</string> + <string name="sort_by_date_added">Datum hinzugefügt</string> + <string name="sort_by_date_episode_added">Datum Episode hinzugefügt</string> + <string name="sort_by_date_played">Datum abgespielt</string> <string name="sort_by_date_released">Erscheinungsdatum</string> <string name="sort_by_random">Zufällig</string> - <string name="submit">Übermitteln</string> - <string name="subtitle_download_too_long">Herunterladen dauert sehr lange, vielleicht hilft ein Neustart der Wiedergabe</string> + <string name="submit">Absenden</string> + <string name="subtitle_download_too_long">Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten</string> <string name="subtitle">Untertitel</string> <string name="suggestions">Vorschläge</string> - <string name="switch_user">Wechseln</string> - <string name="top_unwatched">Am besten bewertet ungesehen</string> - <string name="tv_guide">Guide</string> - <string name="play_with_transcoding">Mit Transcoding wiedergeben</string> + <string name="switch_user">Wechsel</string> + <string name="top_unwatched">Top Bewertet Ungesehen</string> + <string name="tv_guide">Fernsehprogramm</string> + <string name="play_with_transcoding">Mit Transkodierung abspielen</string> <string name="show_clock">Uhr anzeigen</string> <string name="create_playlist">Neue Playlist erstellen</string> <string name="add_to_playlist">Zur Playlist hinzufügen</string> @@ -159,15 +157,15 @@ <string name="official_rating">Altersfreigabe</string> <plurals name="other_extras"> <item quantity="one">Sonstiges</item> - <item quantity="other">Sonstige</item> + <item quantity="other"></item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Hinter den Kulissen</item> - <item quantity="other">Hinter den Kulissen</item> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Nicht verwendete Szene</item> - <item quantity="other">Nicht verwendete Szenen</item> + <item quantity="one">Gelöschte Szenen</item> + <item quantity="other"></item> </plurals> <plurals name="items"> <item quantity="one">%d Eintrag</item> @@ -177,586 +175,109 @@ <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 je Startseiten-Reihe</string> + <string name="max_homepage_items">Max. Anzahl Objekte auf der Startseite</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ</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="live_tv">Live TV</string> + <string name="outro">Outro</string> + <string name="play_count">Wiedergabe Anzahl</string> + <string name="playback_debug_info">Zeige Wiedergabe Debug infos</string> + <string name="playlist">Playlist</string> + <string name="playlists">Playlisten</string> <string name="show_next_up_when">Zeige als nächstes</string> <string name="show">Zeigen</string> <string name="sort_by_name">Name</string> <string name="studios">Studios</string> - <string name="tv_dvr_schedule">Aufnahmeplan</string> - <string name="video_scale">Videoskalierung</string> + <string name="tv_dvr_schedule">DVR Zeitplan</string> + <string name="video_scale">Videogröße</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 Steuerkreuz Mitte zum Pausieren/Fortsetzen</string> + <string name="one_click_pause_summary_on">Drücke D-Pad Mitte zum Pausieren/Fortsetzen</string> <string name="italic_font">Kursivschrift</string> <string name="runtime_sort">Laufzeit</string> <plurals name="theme_songs"> - <item quantity="one">Titelsong</item> - <item quantity="other">Titelsongs</item> + <item quantity="one">Theme Songs</item> + <item quantity="other"></item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Titelvideo</item> - <item quantity="other">Titelvideos</item> + <item quantity="one">Theme Videos</item> + <item quantity="other"></item> </plurals> <plurals name="clips"> - <item quantity="one">Clip</item> - <item quantity="other">Clips</item> + <item quantity="one">Clips</item> + <item quantity="other"/> </plurals> <plurals name="interviews"> - <item quantity="one">Interview</item> - <item quantity="other">Interviews</item> + <item quantity="one">Interviews</item> + <item quantity="other"/> </plurals> <plurals name="samples"> - <item quantity="one">Ausschnitt</item> - <item quantity="other">Ausschnitte</item> + <item quantity="one">Beispiele</item> + <item quantity="other"/> </plurals> <plurals name="featurettes"> - <item quantity="one">Begleitfilm</item> - <item quantity="other">Begleitfilme</item> + <item quantity="one">Begleitfilme</item> + <item quantity="other"/> </plurals> <plurals name="shorts"> - <item quantity="one">Short</item> - <item quantity="other">Shorts</item> + <item quantity="one">Shorts</item> + <item quantity="other"></item> </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-Untertiteln</string> + <string name="direct_play_ass">Direct play ASS subtitles</string> + <string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> - <string name="ffmpeg_extension_pref">FFmpeg-Dekoder-Modul verwenden</string> - <string name="global_content_scale">Standard-Skalierung</string> + <string name="ffmpeg_extension_pref">FFmpeg decoder 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">Titelmusik wiedergeben</string> + <string name="play_theme_music">Themenmusik 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_commercials_behavior">Werbung überspringen</string> + <string name="skip_back_on_resume_preference">Zurückspringen wenn wiedergabe fortgesetzt wird</string> + <string name="skip_commercials_behavior">Überspringe Werbung verhalten</string> <string name="skip_forward_preference">Vorspringen</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="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="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> <string name="font_size">Schriftgröße</string> <string name="font_color">Schriftfarbe</string> - <string name="font_opacity">Deckkraft der Schrift</string> - <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">Untertitelstil</string> + <string name="font_opacity">Schriftart Deckkraft</string> + <string name="edge_style">Rand Stil</string> + <string name="edge_color">Rand Farbe</string> + <string name="background_opacity">Hintergrund Deckktraft</string> + <string name="background_style">Hintergrund Stil</string> + <string name="subtitle_style">Untertitel Stil</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ü anpassen</string> - <string name="nav_drawer_switch_on_focus">Navigationsmenü-Seiten beim Fokus wechseln</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="playback_overrides">Wiedergabe überschreiben</string> - <string name="seek_bar_steps">Zeitleistensprünge</string> + <string name="seek_bar_steps">Zeitleiste Sprü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">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> - <string name="remove">Entfernen</string> - <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">Serveradresse eingeben</string> - <string name="no_servers_found">Keine Server gefunden</string> - <string name="media_information">Medieninformation</string> - <string name="played">Abgespielt</string> - <string name="mpv_use_gpu_next">MPV: Benutze gpu-next</string> - <string name="mpv_conf">mpv.conf bearbeiten</string> - <string name="discard_change">Änderungen verwerfen?</string> - <string name="subtitle_margin">Abstand</string> - <string name="enter_pin">PIN Eingeben</string> - <string name="sign_in_auto">Automatisch anmelden</string> - <string name="require_pin_code">Profil benötigt PIN</string> - <string name="confirm_pin">PIN bestätigen</string> - <string name="incorrect">Falsch</string> - <string name="pin_too_short">PIN muss 4 Zeichen oder länger sein</string> - <string name="will_remove_pin">PIN wird entfernt</string> - <string name="view_options">Anzeigeoptionen</string> - <string name="columns">Spalten</string> - <string name="spacing">Abstände</string> - <string name="aspect_ratio">Seitenverhältnis</string> - <string name="show_details">Details anzeigen</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> - <string name="title">Titel</string> - <string name="profile">Profil</string> - <string name="resolution">Auflösung</string> - <string name="framerate">Bildrate</string> - <string name="language">Sprache</string> - <string name="channels">Kanäle</string> - <string name="yes">Ja</string> - <string name="no">Nein</string> - <string name="show_titles">Titel anzeigen</string> - <string name="live_tv_repeat">Wiederholen</string> - <string name="general">Allgemein</string> - <string name="no_trailers">Keine Trailer</string> - <string name="play_trailer">Trailer abspielen</string> - <string name="local">Lokal</string> - <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> - <string name="cast_and_crew"><![CDATA[Besetzung & Mitwirkende]]></string> - <string name="container">Container</string> - <string name="codec">Codec</string> - <string name="level">Level</string> - <string name="bit_depth">Bittiefe</string> - <string name="avc">AVC</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="only_forced_subtitles">Nur erzwungene Untertitel</string> - <string name="voice_starting">Beginne</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_server">Serverfehler</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="password">Passwort</string> - <string name="username">Benutzername</string> - <string name="url">URL</string> - <string name="request_4k">In 4K anfragen</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="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 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ä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="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">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">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="color_space">Farbraum</string> - <string name="color_transfer">Farbübertragung</string> - <string name="color_primaries">Primärfarben</string> - <string name="pixel_format">Pixelformat</string> - <string name="ref_frames">Referenzbilder</string> - <string name="edge_size">Randgröße</string> - <string name="resolution_switching">Auflösungswechsel</string> - <string name="guest_stars">Gastdarsteller</string> - <string name="slideshow_at_beginning">Ab Anfang</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> - <string name="blur">Verschwommen</string> - <string name="save_for_album">Speichern für Album</string> - <string name="play_slideshow">Diashow abspielen</string> - <string name="stop_slideshow">Diashow anhalten</string> - <string name="brightness">Helligkeit</string> - <string name="contrast">Kontrast</string> - <string name="zoom_out">Verkleinern</string> - <string name="zoom_in">Vergrößern</string> - <string name="quick_connect_success">Gerät erfolgreich authorisiert</string> - <string name="saturation">Sättigung</string> - <string name="hue">Farbton</string> - <plurals name="days"> - <item quantity="one">%s Tag</item> - <item quantity="other">%s Tage</item> - </plurals> - <string name="quick_connect_code_error">Der Code muss aus 6 Ziffern bestehen</string> - <string name="hdr_subtitle_style">HDR-Untertitelstil</string> - <string name="slideshow_duration">Dauer der Diashow</string> - <string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string> - <string name="max_days_next_up">Max. Tage in \"Als Nächstes\"</string> - <string name="quick_connect">Quick Connect</string> - <plurals name="minutes"> - <item quantity="one">%s Minute</item> - <item quantity="other">%s Minuten</item> - </plurals> - <string name="interlaced">Interlaced</string> - <string name="nal">NAL</string> - <string name="quick_connect_summary">Gewähre anderen Geräten Zugang zu deinem Account</string> - <string name="quick_connect_code">Quick Connect-Code eingeben</string> - <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="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> - <string name="overwrite_server_settings">Einstellungen auf dem Server überschreiben?</string> - <string name="overwrite_local_settings">Lokale Einstellungen überschreiben?</string> - <string name="suggestions_for">Vorschläge für %1$s</string> - <string name="send_media_info_log_to_server">Sende Media Info Log zum Server</string> - <string name="customize_home">Anpassung Homepage</string> - <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 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">Bis %s Jahre</string> - <string name="screensaver">Bildschirmschoner</string> - <string name="screensaver_settings">Bildschirmschoner Einstellungen</string> - <string name="start_screensaver">Bildschirmschoner starten</string> - <string name="duration">Dauer</string> - <string name="photos">Fotos</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> - <string name="skip_ignore">Ignorieren</string> - <string name="skip_automatically">Automatisch überspringen</string> - <string name="skip_ask">Vor dem Überspringen nachfragen</string> - <string name="ffmpeg_fallback">FFmpeg nur verwenden, sofern keine anderen Decoder vorhanden sind</string> - <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 des Abspanns</string> - <string name="white">Weiß</string> - <string name="light_gray">Hellgrau</string> - <string name="dark_gray">Dunkelgrau</string> - <string name="yellow">Gelb</string> - <string name="cyan">Cyan</string> - <string name="magenta">Magenta</string> - <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 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="seerr_local_user">Lokaler Benutzer</string> - <string name="search_for">Suche %s</string> - <string name="display_presets">Design-Vorlagen</string> - <string name="animate">Animieren</string> - <string name="content_scale_fill_width">Breite ausfüllen</string> - <string name="content_scale_fill_height">Höhe ausfüllen</string> - <string name="display_preset_default">Wolfskin Standard</string> - <string name="display_preset_compact">Wolfskin Kompakt</string> - <string name="volume_lowest">Niedrigste</string> - <string name="volume_low">Niedrig</string> - <string name="volume_medium">Mittel</string> - <string name="volume_high">Hoch</string> - <string name="volume_full">Voll</string> - <string name="purple">Lila</string> - <string name="orange">Orange</string> - <string name="black">Schwarz</string> - <string name="backdrop_style_image">Nur Bild</string> - <string name="series_continueing">Aktuell</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">Darsteller</string> - <string name="composer">Komponist</string> - <string name="writer">Autor</string> - <string name="guest_star">Gastdarsteller</string> - <string name="producer">Produzent</string> - <string name="conductor">Dirigent</string> - <string name="lyricist">Liedtexter</string> - <string name="artist">Künstler</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> - <string name="no_subtitles_found">Es wurden keine Untertitel gefunden</string> - <string name="arranger">Arrangeur</string> - <string name="engineer">Ingenieur</string> - <string name="bold_blue">Dunkelblau</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-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> - <string name="background_style_boxed">Kasten</string> - <string name="mixer">Mischer</string> - <string name="select_all">Alle auswählen</string> - <string name="albums">Alben</string> - <string name="artists">Interpreten</string> - <string name="songs">Lieder</string> - <string name="go_to_artist">Zu Interpret wechseln</string> - <string name="now_playing">Läuft gerade</string> - <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">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="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">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">Bevorzugt Logo statt Titel anzeigen</string> - <string name="updated_toast">Wolfskin 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="view_more">Mehr anzeigen</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="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 (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="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="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">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> - <string name="view_options_type_list">Liste</string> - <string name="view_options_type_list_compact">Liste kompakt</string> - <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">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">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> - <string name="remove_from_playlist">Von Wiedergabeliste entfernen</string> - <string name="login_credentials_expired">Anmeldeinformationen abgelaufen, bitte erneut anmelden</string> - <string name="login_not_authorized">Du bist aktuell nicht berechtigt auf den Server zuzugreifen</string> - <string name="prefer_dovi_over_hdr10_plus">Dolby Vision gegenüber HDR10+ bevorzugen</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden</string> - <string name="max_resolution">Maximale Auflösung vor dem Transkodieren</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Folge</item> - <item quantity="other">%1$d Folgen</item> - </plurals> - <string name="top_rated">Am besten bewertet</string> - <string name="hdr_formats">HDR Formate</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="server_version_required">Benötigte Server Version: %1$s</string> - <string name="user">Benutzer</string> - <string name="load_from_server_plugin">Standard vom Server laden</string> - <string name="save_to_local">Nur auf diesem Gerät speichern</string> - <string name="home_settings_source_unset">Standard</string> - <string name="home_settings_source_local">Geräte spezifisch</string> - <string name="home_settings_source_server_profile">Server Benutzerprofil</string> - <string name="home_settings_source_plugin">Server Standard</string> - <string name="home_settings_source_web">Benutzer Webclient</string> - <string name="managed_by_server">Vom Server verwaltet</string> - <string name="error">Fehler</string> - <string name="server_version_not_supported_future">Server Version wird bald nicht mehr unterstützt</string> - <string name="chosen_by">Festgelegt von %1$s</string> - <string name="convert_dovi_profile_7">Dolby Vision Profile 7 zu 8.1 konvertieren</string> - <string name="convert_dovi_profile_7_summary">Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt</string> - <string name="artist_source">Interpreten Quelle</string> - <string name="series">Reihen</string> - <string name="force_enable">Aktivieren erzwingen</string> - <string name="force_disable">Deaktivieren erzwingen</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> </resources> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml deleted file mode 100644 index 732ffe5d..00000000 --- a/app/src/main/res/values-el/strings.xml +++ /dev/null @@ -1,393 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="movies_title">Ταινίες</string> - <string name="profile">Προφίλ</string> - <string name="home">Αρχική</string> - <string name="language">Γλώσσα</string> - <string name="app_theme">Θέμα εφαρμογής</string> - <string name="subtitles">Υπότιτλοι</string> - <string name="subtitle">Υπότιτλος</string> - <string name="channels">Κανάλια</string> - <string name="yes">Ναι</string> - <string name="no">Όχι</string> - <string name="advanced_settings">Προηγμένες ρυθμίσεις</string> - <string name="born">Γεννήθηκε</string> - <string name="birthplace">Τόπος γέννησης</string> - <string name="audio">Ήχος</string> - <string name="video">Το βίντεο</string> - <string name="videos">Τα βίντεο</string> - <string name="background">Παρασκήνιο</string> - <plurals name="theme_songs"> - <item quantity="one">Θεματικό τραγούδι</item> - <item quantity="other">Θεματικά τραγούδια</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Θεματικό βίντεο</item> - <item quantity="other">Θεματικά βίντεο</item> - </plurals> - <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_title">Σειρές</string> - <string name="genres">Είδη</string> - <string name="collection">Συλλογή</string> - <string name="collections">Συλλογές</string> - <string name="add_favorite">Αγαπημένο</string> - <string name="favorites">Αγαπημένα</string> - <string name="suggestions">Προτάσεις</string> - <string name="director">Σκηνοθέτης</string> - <string name="studios">Στούντιο</string> - <string name="sdr">SDR</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="path">Διαδρομή</string> - <string name="name">Όνομα</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="default_track">Προεπιλογή</string> - <string name="external_track">Εξωτερικό</string> - <string name="add_user">Προσθήκη χρήστη</string> - <string name="cancel">Ακύρωση</string> - <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="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="critic_rating">Κριτική</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 ή το URL του διακομιστή</string> - <string name="error_loading_collection">Σφάλμα φόρτωσης συλλογής %1$s</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> - <string name="immediate">Άμεσος</string> - <string name="intro">Εισαγωγή</string> - <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_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_update_available">Χωρίς διαθέσιμη ενημέρωση</string> - <string name="play_from_here">Έναρξη από εδώ</string> - <string name="play">Έναρξη</string> - <string name="people_title">Άτομα</string> - <string name="playback_speed">Ταχύτητα αναπαραγωγής</string> - <string name="playback">Αναπαραγωγή</string> - <string name="playlist">Λίστα αναπαραγωγής</string> - <string name="playlists">Λίστες αναπαραγωγής</string> - <string name="preview">Παρουσίαση</string> - <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_recorded">Πρόσφατα καταγεγραμμένο</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="restart">Επανεκκίνηση</string> - <string name="resume">Συνέχισε</string> - <string name="save">Αποθήκευση</string> - <string name="search">Αναζήτηση</string> - <string name="searching">Αναζήτηση…</string> - <string name="select_server">Επιλογή διακομιστή</string> - <string name="select_user">Επιλογή χρήστη</string> - <string name="show_debug_info">Εμφάνιση πληροφοριών εντοπισμού σφαλμάτων</string> - <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="sort_by_date_played">Ημερομηνία αναπαραγωγής</string> - <string name="sort_by_date_released">Ημερομηνία κυκλοφορίας</string> - <string name="sort_by_name">Όνομα</string> - <string name="sort_by_random">Τυχαία</string> - <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="switch_servers">Αλλαγή διακομιστή</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="video_scale">Κλίμακα βίντεο</string> - <string name="watch_live">Ζωντανή μετάδοση</string> - <string name="media_information">Πληροφορίες μέσων</string> - <string name="show_clock">Εμφάνιση ώρας</string> - <string name="create_playlist">Δημιουργία νέας λίστας αναπαραγωγής</string> - <string name="add_to_playlist">Προσθήκη στη λίστα αναπαραγωγής</string> - <string name="one_click_pause">Παύση με ένα κουμπί</string> - <string name="italic_font">Πλάγια γραμματοσειρά</string> - <string name="success">Επιτυχής</string> - <string name="official_rating">Γονεική αξιολόγηση</string> - <string name="runtime_sort">Διάρκεια</string> - <string name="extras_title">Επιπρόσθετα</string> - <plurals name="other_extras"> - <item quantity="one">Άλλο</item> - <item quantity="other">Άλλα</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Παρασκήνιο</item> - <item quantity="other">Παρασκήνια</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Απόσπασμα</item> - <item quantity="other">Αποσπάσματα</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Διαγραμμένη σκηνή</item> - <item quantity="other">Διαγραμμένες σκηνές</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Συνέντευξη</item> - <item quantity="other">Συνεντεύξεις</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Σκηνή</item> - <item quantity="other">Σκηνές</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Δείγμα</item> - <item quantity="other">Δείγματα</item> - </plurals> - <plurals name="featurettes"> - <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="install_update">Εγκατάσταση ενημέρωσης</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 deleted file mode 100644 index 4df26a67..00000000 --- a/app/src/main/res/values-en-rGB/strings.xml +++ /dev/null @@ -1,60 +0,0 @@ -<?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> - <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 authorised to access the server at this time</string> - <string name="clear_image_cache">Clear image cache</string> - <string name="collection">Collection</string> -</resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 714ae611..bf599bc6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,8 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="add_favorite">Añadir a Favoritos</string> - <string name="add_server">Añadir servidor</string> - <string name="add_user">Añadir usuario</string> + <string name="add_favorite">Favorito</string> + <string name="add_server">Agregar servidor</string> + <string name="add_user">Agregar usuario</string> <string name="audio">Audio</string> <string name="birthplace">Lugar de nacimiento</string> <string name="bitrate">Tasa de bits</string> @@ -12,10 +12,10 @@ <string name="choose_stream">Elegir %1$s</string> <string name="collection">Colección</string> <string name="collections">Colecciones</string> - <string name="commercial">Anuncio</string> + <string name="commercial">Comercial</string> <string name="confirm">Confirmar</string> <string name="continue_watching">Continuar viendo</string> - <string name="decimal_seconds">%.2f segundos</string> + <string name="decimal_seconds">%.1f segundos</string> <string name="delete">Eliminar</string> <string name="died">Fallecido</string> <string name="enabled">Activado</string> @@ -24,19 +24,19 @@ <string name="favorites">Favoritos</string> <string name="file_size">Tamaño</string> <string name="genres">Géneros</string> - <string name="home">Inicio</string> + <string name="home">Página de inicio</string> <string name="immediate">Inmediato</string> <string name="intro">Intro</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Biblioteca</string> + <string name="library">Catálogo</string> <string name="license_info">Información de licencia</string> <string name="max_bitrate">Tasa de bits máxima</string> <string name="more">Más</string> <string name="name">Nombre</string> <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> - <string name="outro">Créditos finales</string> - <string name="people_title">Personas</string> + <string name="outro">Outro</string> + <string name="people">Personas</string> <string name="play">Reproducir</string> <string name="playback">Reproducción</string> <string name="playlist">Lista de reproducción</string> @@ -46,16 +46,16 @@ <string name="recently_added_in">Agregado recientemente en %1$s</string> <string name="recently_added">Agregado recientemente</string> <string name="recommended">Recomendado</string> - <string name="restart">Reiniciar</string> + <string name="restart">Reiniciarlo</string> <string name="resume">Reanudar</string> <string name="save">Guardar</string> <string name="search_and_download"><![CDATA[Buscar y descargar]]></string> <string name="search">Buscar</string> <string name="searching">Buscando…</string> <string name="select_server">Seleccionar servidor</string> - <string name="select_user">Seleccionar Usuario</string> + <string name="select_user">Seleccionar usuario</string> <string name="sort_by_name">Nombre</string> - <string name="sort_by_random">Selección aleatoria</string> + <string name="sort_by_random">Aleatorio</string> <string name="studios">Estudios</string> <string name="submit">Enviar</string> <string name="subtitle">Subtítulo</string> @@ -63,34 +63,31 @@ <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áileres</item> - <item quantity="other">Tráileres</item> + <item quantity="one">Tráilers</item> + <item quantity="many"></item> + <item quantity="other"></item> </plurals> - <string name="tv_shows_title">Series</string> + <string name="tv_shows">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> - <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="video">Video</string> + <string name="videos">Videos</string> + <string name="years_old">%1$d años</string> <string name="play_with_transcoding">Reproducir con transcodificación</string> <string name="show_clock">Mostrar reloj</string> <string name="create_playlist">Crear nueva lista de reproducción</string> <string name="add_to_playlist">Agregar a la lista de reproducción</string> <string name="one_click_pause">Pausar con un clic</string> - <string name="one_click_pause_summary_on">Pulsa el botón central de la cruceta para reproducir/pausar</string> + <string name="one_click_pause_summary_on">Presiona el centro del D-Pad para pausar/reproducir</string> <string name="success">Éxito</string> <string name="runtime_sort">Duración</string> - <string name="extras_title">Extras</string> + <string name="extras">Extras</string> <plurals name="other_extras"> - <item quantity="one">Otro</item> - <item quantity="many">Otros</item> - <item quantity="other">Otros</item> + <item quantity="one">Otros</item> + <item quantity="many"></item> + <item quantity="other"></item> </plurals> <string name="advanced_settings">Configuración avanzada</string> <string name="advanced_ui">Interfaz avanzada</string> @@ -98,662 +95,4 @@ <string name="installed_version">Versión instalada</string> <string name="play_theme_music">Reproducir música temática</string> <string name="settings">Configuración</string> - <string name="critic_rating">Calificación de la crítica</string> - <string name="recap">Resumen</string> - <string name="record_program">Grabar Programa</string> - <string name="record_series">Grabar serie</string> - <string name="remove_favorite">Quitar de favoritos</string> - <string name="show_debug_info">Mostrar información de depuración</string> - <string name="show">Mostrar</string> - <string name="shuffle">Reproducción aleatoria</string> - <string name="skip">Saltar</string> - <string name="sort_by_date_episode_added">Ordenar por fecha de inclusión del episodio</string> - <string name="about">Acerca de</string> - <string name="active_recordings">Grabaciones activas</string> - <string name="cancel_recording">Cancelar grabación</string> - <string name="cancel_series_recording">Cancelar grabación de la serie</string> - <string name="clear_image_cache">Borrar caché de imágenes</string> - <string name="community_rating">Valoración de la comunidad</string> - <string name="compose_error_message_title">¡Se ha producido un error! Pulsa el botón para enviar los registros a tu servidor.</string> - <string name="default_track">Predeterminado</string> - <string name="directed_by">Dirigido por %1$s</string> - <string name="director">Director</string> - <string name="disabled">Desactivado</string> - <string name="discovered_servers">Servidores detectados</string> - <string name="download_and_update"><![CDATA[Descargar y actualizar]]></string> - <string name="downloading">Descargando…</string> - <string name="enter_server_url">Introduce la IP o URL del servidor</string> - <string name="error_loading_collection">Error cargando la colección %1$s</string> - <string name="forced_track">Forzado</string> - <string name="go_to_series">Ir a la serie</string> - <string name="go_to">Ir a</string> - <string name="hide_controller_timeout">Ocultar controles de reproducción</string> - <string name="hide_debug_info">Ocultar información de depuración</string> - <string name="hide">Ocultar</string> - <string name="live_tv">TV en vivo</string> - <string name="loading">Cargando…</string> - <string name="mark_entire_series_as_played">¿Marcar la serie completa como vista?</string> - <string name="mark_entire_series_as_unplayed">¿Marcar la serie completa como no vista?</string> - <string name="mark_unwatched">Marcar como no visto</string> - <string name="mark_watched">Marcar como visto</string> - <string name="more_like_this">Más como esto</string> - <string name="movies_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> - <string name="no_update_available">No hay actualizaciones disponibles</string> - <string name="path">Ruta</string> - <string name="play_count">Reproducciones</string> - <string name="play_from_here">Reproducir desde aquí</string> - <string name="playback_debug_info">Mostrar información de depuración de reproducción</string> - <string name="playback_speed">Velocidad de reproducción</string> - <string name="profile_specific_settings">Configuración del perfil de usuario</string> - <string name="recently_recorded">Grabado recientemente</string> - <string name="recently_released">Lanzado recientemente</string> - <string name="show_next_up_when">Mostrar “Siguiente”</string> - <string name="sort_by_date_added">Fecha de inclusión</string> - <string name="sort_by_date_played">Fecha de reproducción</string> - <string name="sort_by_date_released">Fecha de lanzamiento</string> - <string name="subtitle_download_too_long">La descarga está tardando demasiado, es posible que debas reiniciar la reproducción</string> - <string name="top_unwatched">Mejor calificados no vistos</string> - <string name="tv_dvr_schedule">Programación DVR</string> - <string name="tv_guide">Guía</string> - <string name="tv_season">Temporada</string> - <string name="tv_seasons">Temporadas</string> - <string name="unknown">Desconocido</string> - <string name="updates">Actualizaciones</string> - <string name="video_scale">Escala de vídeo</string> - <string name="watch_live">Ver en directo</string> - <string name="aired_episode_order">Orden de emisión</string> - <string name="italic_font">Cursiva</string> - <plurals name="behind_the_scenes"> - <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">Tema musical</item> - <item quantity="many">Temas musicales</item> - <item quantity="other">Temas musicales</item> - </plurals> - <plurals name="theme_videos"> - <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> - <item quantity="many">Clips</item> - <item quantity="other">Clips</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Escena eliminada</item> - <item quantity="many">Escenas eliminadas</item> - <item quantity="other">Escenas eliminadas</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Entrevista</item> - <item quantity="many">Entrevistas</item> - <item quantity="other">Entrevistas</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Escena</item> - <item quantity="many">Escenas</item> - <item quantity="other">Escenas</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Muestra</item> - <item quantity="many">Muestras</item> - <item quantity="other">Muestras</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Reportaje</item> - <item quantity="many">Reportajes</item> - <item quantity="other">Reportajes</item> - </plurals> - <plurals name="shorts"> - <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> - <item quantity="many">%s descargas</item> - <item quantity="other">%s descargas</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d hora</item> - <item quantity="many">%d horas</item> - <item quantity="other">%d horas</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d elemento</item> - <item quantity="many">%d elementos</item> - <item quantity="other">%d elementos</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d segundo</item> - <item quantity="many">%d segundos</item> - <item quantity="other">%d segundos</item> - </plurals> - <string name="ac3_supported">El dispositivo soporta AC3/Dolby Digital</string> - <string name="auto_check_for_updates">Buscar actualizaciones automáticamente</string> - <string name="auto_play_next_delay">Retraso antes de reproducir el siguiente</string> - <string name="auto_play_next">Reproducción automática del siguiente</string> - <string name="check_for_updates">Buscar actualizaciones</string> - <string name="combine_continue_next_summary">Aplica solo a series</string> - <string name="combine_continue_next"><![CDATA[Combinar “Continuar viendo” y “Siguiente”]]></string> - <string name="direct_play_ass">Usar libass para reproducir subtítulos ASS</string> - <string name="direct_play_pgs">Reproducción directa de subtítulos PGS</string> - <string name="downmix_stereo">Convertir siempre a estéreo</string> - <string name="ffmpeg_extension_pref">Usar módulo decodificador FFmpeg</string> - <string name="global_content_scale">Escala de contenido predeterminada</string> - <string name="install_update">Instalar actualización</string> - <string name="max_homepage_items">Elementos máximos en filas de inicio</string> - <string name="nav_drawer_pins_summary">Elige los elementos predeterminados que se mostrarán; los demás permanecerán ocultos</string> - <string name="nav_drawer_pins">Personalizar elementos del panel de navegación</string> - <string name="nav_drawer_switch_on_focus_summary_off">Clic para cambiar de página</string> - <string name="nav_drawer_switch_on_focus">Cambiar páginas del menú al enfocar</string> - <string name="pass_out_protection">Protección contra inactividad</string> - <string name="playback_overrides">Anulaciones de reproducción</string> - <string name="remember_selected_tab">Recordar pestañas seleccionadas</string> - <string name="rewatch_next_up">Permitir volver a ver en “Siguiente”</string> - <string name="seek_bar_steps">Pasos de la barra de búsqueda</string> - <string name="send_app_logs_summary">Útil para depuración</string> - <string name="send_app_logs">Enviar registros de la app al servidor actual</string> - <string name="send_crash_reports_summary">Intentará enviarse al último servidor conectado</string> - <string name="send_crash_reports">Enviar informes de fallos</string> - <string name="skip_back_on_resume_preference">Retroceder al reanudar la reproducción</string> - <string name="skip_back_preference">Retroceder</string> - <string name="skip_commercials_behavior">Comportamiento al saltar anuncios</string> - <string name="skip_forward_preference">Avanzar</string> - <string name="skip_intro_behavior">Comportamiento al saltar la cabecera</string> - <string name="skip_outro_behavior">Comportamiento al saltar créditos finales</string> - <string name="skip_previews_behavior">Comportamiento al saltar avances</string> - <string name="skip_recap_behavior">Comportamiento al saltar resumen</string> - <string name="update_available">Actualización disponible</string> - <string name="update_url_summary">URL usada para comprobar actualizaciones</string> - <string name="update_url">URL de actualización</string> - <string name="font_size">Tamaño de fuente</string> - <string name="font_color">Color de fuente</string> - <string name="font_opacity">Opacidad de fuente</string> - <string name="edge_style">Estilo del borde</string> - <string name="edge_color">Color del borde</string> - <string name="background_opacity">Opacidad del fondo</string> - <string name="background_style">Estilo del fondo</string> - <string name="subtitle_style">Estilo de subtítulos</string> - <string name="background_color">Color del fondo</string> - <string name="reset">Restablecer</string> - <string name="bold_font">Negrita</string> - <string name="player_backend">Motor de reproducción</string> - <string name="mpv_hardware_decoding">MPV: Usar decodificación por hardware</string> - <string name="disable_if_crash">Desactívalo si experimentas fallos</string> - <string name="mpv_options">Opciones de MPV</string> - <string name="exoplayer_options">Opciones de ExoPlayer</string> - <string name="skip_segment_unknown">Saltar segmento</string> - <string name="skip_segment_commercial">Saltar anuncios</string> - <string name="skip_segment_preview">Saltar avance</string> - <string name="skip_segment_recap">Saltar resumen</string> - <string name="skip_segment_outro">Saltar créditos finales</string> - <string name="skip_segment_intro">Saltar cabecera</string> - <string name="played">Reproducido</string> - <string name="filter">Filtrar</string> - <string name="year">Año</string> - <string name="decade">Década</string> - <string name="remove">Eliminar</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="font">Fuente</string> - <string name="background">Fondo</string> - <string name="official_rating">Clasificación parental</string> - <string name="ends_at">Termina a las %1$s</string> - <string name="enter_server_address">Introduzca la dirección del servidor</string> - <string name="no_servers_found">No se encontraron servidores</string> - <string name="media_information">Información multimedia</string> - <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> - <string name="mpv_conf">Editar mpv.conf</string> - <string name="discard_change">¿Descartar los cambios?</string> - <string name="subtitle_margin">Margen</string> - <string name="verbose_logging">Registro detallado</string> - <string name="enter_pin">Introducir PIN</string> - <string name="sign_in_auto">Iniciar sesión automáticamente</string> - <string name="use_server_credentials">Iniciar sesión a través del servidor</string> - <string name="press_enter_to_confirm">Pulsa el centro para confirmar</string> - <string name="require_pin_code">Requerir PIN para el perfil</string> - <string name="confirm_pin">Confirmar PIN</string> - <string name="incorrect">Incorrecto</string> - <string name="pin_too_short">El PIN debe tener 4 teclas o más</string> - <string name="will_remove_pin">Se eliminará el PIN</string> - <string name="image_cache_size">Tamaño de la caché de disco de imágenes (MB)</string> - <string name="view_options">Opciones de visualización</string> - <string name="columns">Columnas</string> - <string name="spacing">Espaciado</string> - <string name="aspect_ratio">Relación de aspecto</string> - <string name="show_details">Mostrar detalles</string> - <string name="image_type">Tipo de imagen</string> - <string name="cast_and_crew"><![CDATA[Reparto y equipo]]></string> - <string name="guest_stars">Estrellas invitadas</string> - <string name="edge_size">Tamaño del borde</string> - <string name="refresh_rate_switching">Cambio de frecuencia de actualización</string> - <string name="automatic">Automático</string> - <string name="username_or_password">Usar nombre de usuario/contraseña</string> - <string name="login">Iniciar sesión</string> - <string name="general">General</string> - <string name="container">Contenedor</string> - <string name="title">Título</string> - <string name="codec">Codec</string> - <string name="profile">Perfil</string> - <string name="level">Nivel</string> - <string name="resolution">Resolución</string> - <string name="anamorphic">Anamórfico</string> - <string name="interlaced">Entrelazado</string> - <string name="framerate">Frecuencia de fotogramas</string> - <string name="bit_depth">Profundidad de bits</string> - <string name="video_range">Rango de vídeo</string> - <string name="video_range_type">Tipo de rango de vídeo</string> - <string name="color_space">Espacio de color</string> - <string name="color_transfer">Transferencia de color</string> - <string name="color_primaries">Primarias de color</string> - <string name="pixel_format">Formato de píxel</string> - <string name="ref_frames">Fotogramas de referencia</string> - <string name="nal">NAL</string> - <string name="language">Idioma</string> - <string name="layout">Distribución</string> - <string name="channels">Canales</string> - <string name="sample_rate">Frecuencia de muestreo</string> - <string name="avc">AVC</string> - <string name="yes">Sí</string> - <string name="no">No</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Mostrar títulos</string> - <string name="live_tv_repeat">Repetir</string> - <string name="favorite_channels_at_beginning">Mostrar primero los canales favoritos</string> - <string name="sort_channels_recently_watched">Ordenar los canales por vistos recientemente</string> - <string name="color_code_programs">Programas codificados por colores</string> - <string name="subtitle_delay">Retardo de subtítulos</string> - <string name="backdrop_display">Estilo del fanart</string> - <string name="only_forced_subtitles">Solo Subtítulos Forzados</string> - <string name="voice_search">Búsqueda por voz</string> - <string name="voice_starting">Iniciando</string> - <string name="voice_search_prompt">Habla para buscar</string> - <string name="processing">Procesando</string> - <string name="press_back_to_cancel">Pulse atrás para cancelar</string> - <string name="voice_error_audio">Error en la grabación de audio</string> - <string name="voice_error_client">Error de reconocimiento de voz</string> - <string name="voice_error_permissions">Se requiere permiso para el micrófono</string> - <string name="voice_error_network">Error de red</string> - <string name="voice_error_network_timeout">Tiempo de espera de red agotado</string> - <string name="voice_error_no_match">No se reconoce el habla</string> - <string name="voice_error_busy">Reconocimiento de voz ocupado</string> - <string name="voice_error_server">Error de servidor</string> - <string name="voice_error_speech_timeout">No se detecta voz</string> - <string name="voice_error_unknown">Error desconocido</string> - <string name="voice_error_start_failed">Error al iniciar el reconocimiento de voz</string> - <string name="voice_error_timeout">Se agotó el tiempo de espera para el reconocimiento de voz</string> - <string name="retry">Reintentar</string> - <string name="resolution_switching">Cambio de resolución</string> - <string name="local">Local</string> - <string name="play_trailer">Reproducir trailer</string> - <string name="no_trailers">Sin trailers</string> - <string name="clear_track_choices">Opciones de pista claras</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">Reproducción directa de Dolby Vision Perfil 7</string> - <string name="force_dovi_profile_7_summary">Ignora las comprobaciones de compatibilidad del dispositivo</string> - <string name="discover">Descubrir</string> - <string name="request">Solicitar</string> - <string name="pending">Pendiente</string> - <string name="seerr_integration">Integración con Seerr</string> - <string name="remove_seerr_server">Eliminar servidor Seerr</string> - <string name="seerr_server_added">Servidor Seerr añadido</string> - <string name="password">Contraseña</string> - <string name="username">Usuario</string> - <string name="url">URL</string> - <string name="trending">Tendencias</string> - <string name="upcoming_movies">Próximos estrenos</string> - <string name="upcoming_tv">Próximos programas de televisión</string> - <string name="request_4k">Solicitar en 4K</string> - <string name="software_decoding_av1">Decodificación por software AV1</string> - <string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string> - <plurals name="days"> - <item quantity="one">%s día</item> - <item quantity="many">%s días</item> - <item quantity="other">%s días</item> - </plurals> - <string name="quick_connect">Conexión Rápida</string> - <string name="quick_connect_summary">Autoriza a otro dispositivo para acceder a tu cuenta</string> - <string name="quick_connect_code">Introduce el código de Conexión Rápida</string> - <string name="quick_connect_code_error">El código debe tener 6 dígitos</string> - <string name="quick_connect_success">Dispositivo autorizado con éxito</string> - <string name="hdr_subtitle_style">Estilo de subtítulos HDR</string> - <string name="image_subtitle_opacity">Opacidad de los subtítulos</string> - <string name="brightness">Brillo</string> - <string name="contrast">Contraste</string> - <string name="saturation">Saturación</string> - <string name="hue">Tono de color</string> - <string name="red">Rojo</string> - <string name="green">Verde</string> - <string name="blue">Azul</string> - <string name="blur">Desenfoque</string> - <string name="save_for_album">Guardar en el álbum</string> - <string name="play_slideshow">Reproducir presentación</string> - <string name="stop_slideshow">Detener presentación</string> - <string name="slideshow_at_beginning">Presentación inicial</string> - <string name="no_more_images">No hay más imágenes</string> - <string name="rotate_left">Girar a la izquierda</string> - <string name="rotate_right">Girar a la derecha</string> - <string name="zoom_in">Acercar</string> - <string name="zoom_out">Alejar</string> - <string name="slideshow_duration">Duración de la presentación</string> - <string name="play_videos_during_slideshow">Reproducir vídeos durante la presentación</string> - <string name="send_media_info_log_to_server">Enviar registro de medios al servidor</string> - <string name="no_limit">Sin límite</string> - <string name="max_days_next_up">Límite de días en \"Siguiente\"</string> - <string name="add_row">Añadir fila</string> - <string name="genres_in">Géneros de %1$s</string> - <string name="height">Altura</string> - <string name="apply_all_rows">Aplicar a todas las filas</string> - <string name="customize_home">Personalizar página de inicio</string> - <string name="home_rows">Filas de inicio</string> - <string name="load_from_server">Cargar perfil de usuario desde el servidor</string> - <string name="save_to_server">Guardar perfil de usuario en el servidor</string> - <string name="load_from_web_client">Cargar del cliente web</string> - <string name="use_series">Usar imágenes de la serie</string> - <string name="add_row_for">Agregar fila para %1$s</string> - <string name="overwrite_server_settings">¿Sobrescribir ajustes en el servidor?</string> - <string name="overwrite_local_settings">¿Sobrescribir ajustes locales?</string> - <string name="for_episodes">Para episodios</string> - <string name="suggestions_for">Sugerencias para %1$s</string> - <string name="increase_all_cards_size">Aumentar tamaño de todas las tarjetas</string> - <string name="decrease_all_cards_size">Reducir tamaño de todas las tarjetas</string> - <string name="use_thumb_images">Usar miniaturas</string> - <string name="settings_saved">Ajustes guardados</string> - <string name="display_presets">Preajustes de visualización</string> - <string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string> - <string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string> - <string name="recently_released_in">Lanzado recientemente en %1$s</string> - <string name="favorite_items_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> - <string name="content_scale_fill_width">Ajustar al ancho</string> - <string name="content_scale_fill_height">Ajustar al alto</string> - <string name="display_preset_series_thumb">Miniatura de la serie</string> - <string name="display_preset_episode_thumbnails">Miniatura del episodio</string> - <string name="volume_lowest">Mínimo</string> - <string name="volume_low">Bajo</string> - <string name="volume_medium">Medio</string> - <string name="volume_high">Alto</string> - <string name="volume_full">Máximo</string> - <string name="purple">Morado</string> - <string name="orange">Naranja</string> - <string name="bold_blue">Azul intenso</string> - <string name="black">Negro</string> - <string name="display_preset_default">Póster</string> - <string name="display_preset_compact">Póster compacto</string> - <string name="skip_ignore">Ignorar</string> - <string name="skip_automatically">Omitir automáticamente</string> - <string name="skip_ask">Preguntar para omitir</string> - <string name="ffmpeg_fallback">Usar FFmpeg solo si no existe un decodificador integrado</string> - <string name="ffmpeg_prefer">Preferir FFmpeg sobre los decodificadores integrados</string> - <string name="ffmpeg_never">No usar nunca decodificadores FFmpeg</string> - <string name="next_up_playback_end">Al finalizar la reproducción</string> - <string name="next_up_outro">Durante los créditos finales</string> - <string name="white">Blanco</string> - <string name="light_gray">Gris claro</string> - <string name="dark_gray">Gris oscuro</string> - <string name="yellow">Amarillo</string> - <string name="cyan">Cian</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contorno</string> - <string name="subtitle_edge_shadow">Sombra</string> - <string name="background_style_wrap">Ajustado al texto</string> - <string name="background_style_boxed">Enmarcado</string> - <string name="prefer_mpv">Preferir MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reproducir contenido HDR</string> - <string name="aspect_ratios_poster">Póster (2:3)</string> - <string name="aspect_ratios_16_9">16:9</string> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Cuadrada (1:1)</string> - <string name="image_type_primary">Primaria</string> - <string name="image_type_thumb">Miniatura</string> - <string name="backdrop_style_dynamic">Imagen con color dinámico</string> - <string name="backdrop_style_image">Imagen</string> - <string name="enter_url_api_key"><![CDATA[Introduce la URL y la clave API]]></string> - <string name="api_key">Clave API</string> - <string name="seerr_login">Iniciar sesión en el servidor Seerr</string> - <string name="seerr_jellyfin_user">Usuario de Jellyfin</string> - <string name="seerr_local_user">Usuario local</string> - <string name="search_and_download_subtitles"><![CDATA[Buscar y descargar subtítulos]]></string> - <string name="no_subtitles_found">No se encontraron subtítulos externos</string> - <string name="series_continueing">Actualidad</string> - <plurals name="minutes"> - <item quantity="one">%s minuto</item> - <item quantity="many">%s minutos</item> - <item quantity="other">%s minutos</item> - </plurals> - <string name="start_after">Comenzar después de</string> - <string name="animate">Animación</string> - <string name="max_age_rating">Clasificación por edad máxima</string> - <string name="for_all_ages">Para todas las edades</string> - <string name="up_to_age">Menores de %s</string> - <string name="screensaver">Salvapantallas</string> - <string name="screensaver_settings">Configuración de salvapantallas</string> - <string name="start_screensaver">Iniciar salvapantallas</string> - <string name="duration">Duración</string> - <string name="photos">Fotos</string> - <string name="include_types">Incluir tipos</string> - <string name="include_types_summary">Qué tipos de elementos mostrar</string> - <string name="in_app_screensaver">Usar salvapantallas de la aplicación</string> - <string name="no_max">Sin límite</string> - <string name="actor">Intérprete</string> - <string name="composer">Compositor</string> - <string name="writer">Guionista</string> - <string name="guest_star">Estrella invitada</string> - <string name="producer">Productor</string> - <string name="conductor">Director musical</string> - <string name="lyricist">Letrista</string> - <string name="arranger">Arreglista</string> - <string name="engineer">Ingeniero de sonido</string> - <string name="mixer">Ingeniero de mezcla</string> - <string name="creator">Creador</string> - <string name="artist">Artista</string> - <string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string> - <string name="show_media_management">Mostrar opciones de gestión de medios</string> - <string name="search_for">Buscar %s</string> - <string name="select_all">Seleccionar todo</string> - <string name="show_album_cover">Mostrar portada del álbum</string> - <string name="show_visualizer">Mostrar visualizador</string> - <string name="show_backdrop">Mostrar imagen de fondo</string> - <string name="play_as_type">Modo de reproducción</string> - <string name="visualizer_rationale">Para visualizar el audio en reproducción, se requiere permiso para grabar audio.</string> - <string name="continue_string">Continuar</string> - <string name="albums">Álbumes</string> - <string name="artists">Artistas</string> - <string name="songs">Canciones</string> - <string name="go_to_artist">Ir al artista</string> - <string name="now_playing">Reproduciendo</string> - <string name="instant_mix">Mezcla instantánea</string> - <string name="go_to_album">Ir al álbum</string> - <string name="add_to_queue">Añadir a la cola</string> - <string name="album_artist">Artista del álbum</string> - <string name="album">Álbum</string> - <string name="popular_songs">Más escuchadas</string> - <string name="play_next">Reproducir a continuación</string> - <string name="music_videos">Videoclips</string> - <string name="lyrics">Letras</string> - <string name="hide_lyrics">Ocultar letras</string> - <string name="show_lyrics">Mostrar letras</string> - <string name="song_has_lyrics">Canción con letra</string> - <string name="remove_from_queue">Eliminar de la cola</string> - <string name="separate_types">Separar por tipos</string> - <string name="prefer_logos">Preferir logotipos para los títulos</string> - <string name="updated_toast">Wolfskin se ha actualizado a la versión %s</string> - <string name="view_more">Ver más</string> - <string name="discover_tv">Descubrir series</string> - <string name="discover_movies">Descubrir películas</string> - <string name="remove_continue_watching">Eliminar de \"Continuar viendo\"</string> - <string name="remove_next_up">Eliminar de \"Siguiente\"</string> - <string name="view_removed_next_up">Ver series eliminadas de \"Siguiente\"</string> - <string name="studios_in">Estudios en %1$s</string> - <string name="ass_subtitle_mode_libass">Reproducción directa con libass</string> - <string name="ass_subtitle_mode_exoplayer">Reproducción directa con ExoPlayer</string> - <string name="ass_subtitle_mode_transcode">Incrustar/transcodificar en el servidor</string> - <string name="ass_subtitle_playback">Reproducción de subtítulos SSA/ASS</string> - <string name="last_played">Última reproducción</string> - <string name="next_up_never">Nunca</string> - <string name="external_player">Reproductor externo</string> - <string name="system_default">Predeterminado del sistema</string> - <string name="show_more">Muestra %1$s más</string> - <string name="skip_behavior">Comportamiento al saltar</string> - <string name="skip_behavior_summary">Para cabecera, créditos finales, etc</string> - <string name="cinema_mode">Modo cine</string> - <string name="play_with">Reproducir con</string> - <string name="transcoding">Transcodificar</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="combined_search_results">Resultados de búsqueda combinados</string> - <string name="combined_search_results_on">Mostrar todos los resultados en una sola lista</string> - <string name="combined_search_results_off">Mostrar resultados por categoría</string> - <string name="results">Resultados</string> - <string name="continue_watching_click_behavior">Comportamiento al pulsar en “Seguir viendo” / “Siguiente”</string> - <string name="continue_watching_click_summary_on">Reproducir al pulsar</string> - <string name="continue_watching_click_summary_off">Ver detalles al pulsar</string> - <string name="require_login">Requerir inicio de sesión para el perfil</string> - <string name="display_toggles_title">Visualización de valoraciones</string> - <string name="display_toggles_summary">Personalizar qué mostrar</string> - <string name="profile_protection">Protección del perfil</string> - <string name="stars">Estrellas</string> - <string name="tomatoes">\"Tomates\"</string> - <string name="show_voice_search_button">Mostrar botón de búsqueda por voz</string> - <string name="visible_ui">Visible</string> - <string name="hidden_ui">Oculto</string> - <string name="user_interface_language">Idioma de la interfaz de usuario</string> - <string name="view_options_type_grid">Cuadrícula</string> - <string name="view_options_type_list">Lista</string> - <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> - <string name="remove_from_playlist">Eliminar de la lista de reproducción</string> - <string name="login_credentials_expired">Credenciales caducadas, vuelve a iniciar sesión</string> - <string name="login_not_authorized">No tienes autorización para acceder al servidor en este momento</string> - <string name="prefer_dovi_over_hdr10_plus">Preferir Dolby Vision sobre HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+</string> - <string name="max_resolution">Resolución máxima antes de transcodificar</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Episodio</item> - <item quantity="many">%1$d Episodios</item> - <item quantity="other">%1$d Episodios</item> - </plurals> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 2008bf2c..13c46fa9 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -22,7 +22,7 @@ <string name="confirm">Kinnita</string> <string name="continue_watching">Jätka vaatamist</string> <string name="critic_rating">Kriitikute hinnang</string> - <string name="decimal_seconds">%.2f sekundit</string> + <string name="decimal_seconds">%.1f sekundit</string> <string name="default_track">Vaikimisi</string> <string name="delete">Kustuta</string> <string name="died">Surmaaeg</string> @@ -34,7 +34,7 @@ <string name="download_and_update"><![CDATA[Laadi alla ja uuenda]]></string> <string name="downloading">Allalaadimisel…</string> <string name="enabled">Kasutusel</string> - <string name="enter_server_url">Sisesta serveri IP-aadress või võrguaadress</string> + <string name="enter_server_url">Sisesta serveri IP-aadress või võrguaadress koos pordinumbriga</string> <string name="episodes">Osad</string> <string name="error_loading_collection">Viga kogumiku laadimisel: %1$s</string> <string name="external_track">Väline</string> @@ -62,9 +62,9 @@ <string name="max_bitrate">Suurim bitikiirus</string> <string name="more_like_this">Veel sarnaseid</string> <string name="more">Veel</string> - <string name="movies_title">Filmid</string> + <string name="movies">Filmid</string> <string name="name">Nimi</string> - <string name="next_up">Järgmisena esitamisel</string> + <string name="next_up">Järgmisena tulekul</string> <string name="no_data">Andmed puuduvad</string> <string name="no_results">Tulemusi pole</string> <string name="no_scheduled_recordings">Ajastatud salvestusi pole</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_title">Inimesed</string> + <string name="people">Inimesed</string> <string name="play_count">Esitusi</string> <string name="play_from_here">Esita siit kohast</string> <string name="play">Esita</string> @@ -123,50 +123,50 @@ <string name="success">Valmis</string> <string name="official_rating">Vanemlik hinnang</string> <string name="runtime_sort">Kestus</string> - <string name="extras_title">Lisad</string> + <string name="extras">Lisad</string> <plurals name="other_extras"> <item quantity="one">Muu</item> - <item quantity="other">Muud</item> + <item quantity="other"></item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Kaadri taga</item> - <item quantity="other">Kaadri taga</item> + <item quantity="other"></item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tunnuslugu</item> - <item quantity="other">Tunnuslood</item> + <item quantity="one">Tunnuslood</item> + <item quantity="other"></item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tunnusvideo</item> - <item quantity="other">Tunnusvideod</item> + <item quantity="one">Tunnusvideod</item> + <item quantity="other"></item> </plurals> <plurals name="clips"> - <item quantity="one">Klipp</item> - <item quantity="other">Klipid</item> + <item quantity="one">Klipid</item> + <item quantity="other"></item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Väljajäetud lõik</item> - <item quantity="other">Väljajäetud lõigud</item> + <item quantity="one">Väljajäetud lõigud</item> + <item quantity="other"></item> </plurals> <plurals name="interviews"> - <item quantity="one">Intervjuu</item> - <item quantity="other">Intervjuud</item> + <item quantity="one">Intervjuud</item> + <item quantity="other"></item> </plurals> <plurals name="scenes"> - <item quantity="one">Stseen</item> - <item quantity="other">Stseenid</item> + <item quantity="one">Stseenid</item> + <item quantity="other"></item> </plurals> <plurals name="samples"> - <item quantity="one">Näidislõik</item> - <item quantity="other">Näidislõigud</item> + <item quantity="one">Näited</item> + <item quantity="other"></item> </plurals> <plurals name="featurettes"> - <item quantity="one">Keskmise pikkusega film</item> - <item quantity="other">Keskmise pikkusega filmid</item> + <item quantity="one">Keskmise pikkusega filmid</item> + <item quantity="other"></item> </plurals> <plurals name="shorts"> - <item quantity="one">Lühifilm</item> - <item quantity="other">Lühifilmid</item> + <item quantity="one">Lühifilmid</item> + <item quantity="other"></item> </plurals> <plurals name="downloads"> <item quantity="one">%s allalaadimine</item> @@ -186,577 +186,4 @@ </plurals> <string name="mpv_options">MPV valikud</string> <string name="exoplayer_options">ExoPlayeri valikud</string> - <string name="tv_season">Hooaeg</string> - <string name="tv_seasons">Hooajad</string> - <string name="ui_interface">Kasutajaliides</string> - <string name="unknown">Pole teada</string> - <string name="updates">Uuendused</string> - <string name="version">Versioon</string> - <string name="remove">Eemalda</string> - <string name="decade">Dekaad</string> - <string name="year">Aasta</string> - <string name="filter">Filter</string> - <string name="played">Esitatud</string> - <string name="skip_segment_intro">Jäta sissejuhatus vahele</string> - <string name="skip_segment_outro">Jäta väljajuhatus vahele</string> - <string name="skip_segment_recap">Jäta kokkuvõte vahele</string> - <string name="skip_segment_preview">Jäta eelvaade vahele</string> - <string name="skip_segment_commercial">Jäta reklaamid vahele</string> - <string name="skip_segment_unknown">Jäta segment vahele</string> - <string name="disable_if_crash">Kui rakendus kipub kokku jooksma, siis lülita see eelistus välja</string> - <string name="mpv_hardware_decoding">MPV: kasuta raudvaralist dekodeerimist</string> - <string name="player_backend">Taasesituse taustateenus</string> - <string name="bold_font">Paks kirjatüüp</string> - <string name="reset">Lähtesta</string> - <string name="background_color">Taustavärv</string> - <string name="subtitle_style">Subtiitrite stiil</string> - <string name="background_style">Tausta stiil</string> - <string name="background_opacity">Tausta läbipaistmatus</string> - <string name="edge_color">Äärte värv</string> - <string name="edge_style">Äärte stiil</string> - <string name="font_opacity">Kirjatüübi läbipaistmatus</string> - <string name="font_color">Kirjatüübi värv</string> - <string name="font_size">Kirjatüübi suurus</string> - <string name="record_program">Salvesta programm</string> - <string name="record_series">Salvesta telesari</string> - <string name="top_unwatched">Parima hinnanguga vaatamata sisu</string> - <plurals name="trailers"> - <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_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> - <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> - <string name="create_playlist">Koosta uus esitusloend</string> - <string name="add_to_playlist">Lisa esitusloendisse</string> - <string name="one_click_pause">Peata ühe klõspuga</string> - <string name="one_click_pause_summary_on">Peatamiseks või esitamiseks vajuta D-Pad\'i keskele</string> - <string name="italic_font">Näita kirjatüüpi kaldkirjas</string> - <string name="ac3_supported">Seade toetab koodekeid: AC3/Dolby Digital</string> - <string name="advanced_settings">Täiendavad seadistused</string> - <string name="advanced_ui">Täiendavad kasutajaliidese võimalused</string> - <string name="app_theme">Rakenduse kujundus</string> - <string name="auto_check_for_updates">Kontrolli uuendusi automaatselt</string> - <string name="auto_play_next_delay">Viivitus enne järgmisena ootel oleva sisu esitamist</string> - <string name="auto_play_next">Esita järgmisena ootel olev sisu automaatselt</string> - <string name="check_for_updates">Kontrolli uuendusi</string> - <string name="combine_continue_next_summary">Kehtib vaid telesarjade puhul</string> - <string name="settings">Seadistused</string> - <string name="direct_play_ass">Kasuta ASS-vormingus subtiitrite jaoks libass teeki</string> - <string name="direct_play_pgs">Esita PGS-vormingus subtiitreid otse</string> - <string name="downmix_stereo">Alati vähenda helikanalite arvu stereoni</string> - <string name="ffmpeg_extension_pref">Kasuta FFmpegi dekooderi moodulit</string> - <string name="install_update">Paigalda uuendus</string> - <string name="installed_version">Paigaldatud versioon</string> - <string name="max_homepage_items">Maksimaalne objektide arv avalehe ridades</string> - <string name="nav_drawer_pins_summary">Vali vaikimisi näidatavad objektid, kui muud jäävad peidetuks</string> - <string name="nav_drawer_switch_on_focus_summary_off">Lehtede vahetamiseks klõpsi</string> - <string name="play_theme_music">Esita muusikalist teemat</string> - <string name="playback_overrides">Taasesituse sürjutamised</string> - <string name="remember_selected_tab">Jäta valitud vahekaardid meelde</string> - <string name="seek_bar_steps">Kerimisriba sammud</string> - <string name="send_app_logs_summary">Kasulik veaotsingu puhul</string> - <string name="send_app_logs">Saada rakenduse logid kasutatavale serverile</string> - <string name="send_crash_reports_summary">Proovib saata viimatikasutatud serverile</string> - <string name="send_crash_reports">Saada kokkujooksmise aruanded</string> - <string name="update_available">Uuendus on saadaval</string> - <string name="update_url_summary">Uuenduste kontrollimiseks kasutatav võrguaadress</string> - <string name="update_url">Uuendamise võrguaadress</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="combine_continue_next"><![CDATA[Kombineeri „Jätka vaatamist“ ja „Järgmisena esitamisel“ valikud]]></string> - <string name="global_content_scale">Sisu vaikimisi mõõtkava</string> - <string name="nav_drawer_pins">Kohenda juhtriba objekte</string> - <string name="nav_drawer_switch_on_focus">Fookuse korral vaheta juhtriba lehti</string> - <string name="rewatch_next_up">Luba järgmisena esitatava sisu uuesti vaatamist</string> - <string name="skip_commercials_behavior">Käitumine reklaamide vahelejätmisel</string> - <string name="skip_back_preference">Jäta vahele tagasisuunas</string> - <string name="skip_forward_preference">Jäta vahele edasisuunas</string> - <string name="skip_intro_behavior">Käitumine sissejuhatuse vahelejätmisel</string> - <string name="skip_outro_behavior">Käitumine väljajuhatuse vahelejätmisel</string> - <string name="skip_previews_behavior">Käitumine eelvaate vahelejätmisel</string> - <string name="skip_recap_behavior">Käitumine sisukokkuvõtte vahelejätmisel</string> - <string name="skip_back_on_resume_preference">Taasesituse jätkamisel jäta vahele tagasisuunas</string> - <string name="pass_out_protection">Vaataja uinumise taimer</string> - <string name="subtitle_margin">Veeris</string> - <string name="verbose_logging">Detailne logimine</string> - <string name="image_cache_size">Pildipuhvri suurus (MB)</string> - <string name="enter_pin">Sisesta PIN-kood</string> - <string name="sign_in_auto">Logi automaatselt sisse</string> - <string name="use_server_credentials">Logi sisse serveri abil</string> - <string name="press_enter_to_confirm">Kinnitamiseks vajuta keskele</string> - <string name="require_pin_code">Küsi profiili jaoks PIN-koodi</string> - <string name="confirm_pin">Korda PIN-koodi</string> - <string name="incorrect">Pole õige</string> - <string name="pin_too_short">PIN-kood peab olema 4 või enam märki pikk</string> - <string name="will_remove_pin">Sellega eemaldatakse PIN-kood</string> - <string name="mpv_use_gpu_next">MPV: kasuta gpu-next liidestust</string> - <string name="mpv_conf">Muuda mpv.conf faili</string> - <string name="discard_change">Kas loobud muudatustest?</string> - <string name="view_options">Vaate seaded</string> - <string name="columns">Veerud</string> - <string name="spacing">Ruumivahed</string> - <string name="aspect_ratio">Küljesuhe</string> - <string name="show_details">Näita üksikasju</string> - <string name="image_type">Pildi tüüp</string> - <string name="cast_and_crew"><![CDATA[Näitlejad ja tegijad]]></string> - <string name="guest_stars">Külalisnäitlejad</string> - <string name="edge_size">Ääre suurus</string> - <string name="refresh_rate_switching">Kaadrisageduse vahetamine</string> - <string name="automatic">Automaatne</string> - <string name="username_or_password">Pruugi kasutajanime ja salasõna</string> - <string name="login">Logi sisse</string> - <string name="show_titles">Näita pealkirju</string> - <string name="media_information">Meediumi teave</string> - <string name="general">Üldist</string> - <string name="container">Konteiner</string> - <string name="title">Pealkiri</string> - <string name="codec">Koodek</string> - <string name="profile">Profiil</string> - <string name="level">Tase</string> - <string name="resolution">Resolutsioon</string> - <string name="yes">Jah</string> - <string name="no">Ei</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="bit_unit">bitti</string> - <string name="hlg">HLG</string> - <string name="sample_rate_unit">Hz</string> - <string name="hdr10_plus">HDR10+</string> - <string name="sdr">SDR</string> - <string name="avc">AVC</string> - <string name="anamorphic">Anamorfne</string> - <string name="interlaced">Ülereaskaneering</string> - <string name="framerate">Kaadrisagedus</string> - <string name="bit_depth">Bitisügavus</string> - <string name="video_range">Video bitisügavus</string> - <string name="video_range_type">Video bitisügavuse tüüp</string> - <string name="color_space">Värviruum</string> - <string name="color_transfer">Värvide ülekanne</string> - <string name="language">Keel</string> - <string name="layout">Paigutus</string> - <string name="channels">Kanaleid</string> - <string name="sample_rate">Diskreetimissagedus</string> - <string name="color_primaries">Põhivärvused</string> - <string name="pixel_format">Pikslivorming</string> - <string name="ref_frames">Aluskaadreid</string> - <string name="nal">NAL</string> - <string name="live_tv_repeat">Kordus</string> - <string name="favorite_channels_at_beginning">Näita esmalt lemmikkanaleid</string> - <string name="sort_channels_recently_watched">Järjesta kanalid viimase vaatamise alusel</string> - <string name="color_code_programs">Märgi programmid värvidega</string> - <string name="subtitle_delay">Subtiitrite viivitus</string> - <string name="enter_server_address">Sisesta serveri aadress</string> - <string name="no_servers_found">Ühtegi serverit ei leidu</string> - <string name="backdrop_display">Tausta dekoratsioon</string> - <string name="ends_at">Lõppeb kell %1$s</string> - <string name="resolution_switching">Resolutsiooni vahetamine</string> - <string name="local">Kohalik</string> - <string name="play_trailer">Esita treilerit</string> - <string name="no_trailers">Treilerit pole</string> - <string name="clear_track_choices">Eemalda rajavalikud</string> - <string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</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="force_dovi_profile_7">Dolby Vision Profile 7 otseesitusega</string> - <string name="force_dovi_profile_7_summary">Eirab seadme ühilduvuskontrolle</string> - <string name="dts">DTS</string> - <string name="discover">Otsi ja leia</string> - <string name="request">Päring</string> - <string name="pending">Ootel</string> - <string name="seerr_integration">Seerri lõiming</string> - <string name="remove_seerr_server">Eemalda Seerri server</string> - <string name="password">Salasõna</string> - <string name="username">Kasutajanimi</string> - <string name="url">Võrguaadress</string> - <string name="trending">Populaarsust koguv</string> - <string name="upcoming_movies">Lähiajal avaldatavad filmid</string> - <string name="upcoming_tv">Lähiajal avaldatavad telesarjad ja -saated</string> - <string name="request_4k">Küsi 4K versiooni</string> - <string name="seerr_server_added">Seerri server on lisatud</string> - <string name="voice_search">Häälotsing</string> - <string name="voice_starting">Alustan</string> - <string name="voice_search_prompt">Otsimiseks kõnele</string> - <string name="processing">Töötlen</string> - <string name="press_back_to_cancel">Katkestamiseks vajuta Tagasi-nuppu</string> - <string name="voice_error_audio">Heli salvestamise viga</string> - <string name="voice_error_client">Kõnetuvastamise viga</string> - <string name="voice_error_permissions">Vajalik on õigus kasutada mikrofoni</string> - <string name="voice_error_network">Võrguühenduse viga</string> - <string name="voice_error_network_timeout">Võrguühenduse aegumine</string> - <string name="voice_error_no_match">Pole midagi kõneldut, mida tuvastada</string> - <string name="voice_error_busy">Kõnetuvastus on hõivatud</string> - <string name="voice_error_server">Serveri viga</string> - <string name="voice_error_speech_timeout">Pole tuvastatavat kõnelemist</string> - <string name="voice_error_unknown">Tundmatu viga</string> - <string name="voice_error_start_failed">Kõnetuvastuse käivitamine ei õnnestunud</string> - <string name="voice_error_timeout">Kõnetuvastus aegus</string> - <string name="retry">Proovi uuesti</string> - <string name="software_decoding_av1">AV1 tarkvaraline dekodeerimine</string> - <string name="upgrade_mpv_toast">MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest.</string> - <string name="hdr_subtitle_style">HDR-i tiitrite stiil</string> - <string name="image_subtitle_opacity">Pildiliste tiitrite läbipaistvus</string> - <string name="brightness">Eredus</string> - <string name="contrast">Kontrastsus</string> - <string name="saturation">Küllastus</string> - <string name="hue">Värvitoon</string> - <string name="red">Punane</string> - <string name="green">Roheline</string> - <string name="blue">Sinine</string> - <string name="blur">Hägustamine</string> - <string name="save_for_album">Salvesta albumi jaoks</string> - <string name="play_slideshow">Näita slaidiesitlust</string> - <string name="stop_slideshow">Peata slaidiesitlus</string> - <string name="slideshow_at_beginning">Alguses</string> - <string name="no_more_images">Rohkem fotosid pole</string> - <string name="rotate_left">Pööra vasakule</string> - <string name="rotate_right">Pööra paremale</string> - <string name="zoom_in">Suumi sisse</string> - <string name="zoom_out">Suumi välja</string> - <string name="slideshow_duration">Slaidiesitluse kestus</string> - <string name="play_videos_during_slideshow">Slaidiesitluse ajal esita videoid</string> - <plurals name="days"> - <item quantity="one">%s päev</item> - <item quantity="other">%s päeva</item> - </plurals> - <string name="quick_connect">Kiirühendus</string> - <string name="quick_connect_summary">Luba muul seadmel logida sinu kasutajakontoga sisse</string> - <string name="quick_connect_code">Sisesta kiirühenduse kood</string> - <string name="quick_connect_code_error">Kood peab olema kuuenumbriline</string> - <string name="quick_connect_success">Seadme autentimine õnnestus</string> - <string name="send_media_info_log_to_server">Salvesta meediumi infologi serverisse</string> - <string name="no_limit">Piirangut pole</string> - <string name="max_days_next_up">Järgmisena esitamisel sisu päevade ülempiir</string> - <string name="add_row">Lisa rida</string> - <string name="genres_in">Žanrid: %1$s</string> - <string name="recently_released_in">Hiljuti avaldatud: %1$s</string> - <string name="height">Kõrgus</string> - <string name="apply_all_rows">Rakenda kõikidele ridadele</string> - <string name="customize_home">Kohanda kodulehte</string> - <string name="home_rows">Kodulehe/avalehe read</string> - <string name="load_from_server">Laadi kasutajaprofiil serverist</string> - <string name="save_to_server">Salvesta kasutajaprofiil serverisse</string> - <string name="load_from_web_client">Laadi veebikliendist</string> - <string name="use_series">Kasuta sarja pilti</string> - <string name="add_row_for">Lisa rida: %1$s</string> - <string name="overwrite_server_settings">Kas kirjutad serveris leiduvad seadistused üle?</string> - <string name="overwrite_local_settings">Kas kirjutad kohalikud seadistused üle?</string> - <string name="for_episodes">Episoodide jaoks</string> - <string name="suggestions_for">Soovitused: %1$s</string> - <string name="increase_all_cards_size">Suurenda suurust kõikide kaartide jaoks</string> - <string name="decrease_all_cards_size">Vähenda suurust kõikide kaartide jaoks</string> - <string name="use_thumb_images">Kasuta pisipilte</string> - <string name="settings_saved">Seadistused on salvesatatud</string> - <string name="display_presets">Kuvamise eelseadistused</string> - <string name="display_presets_description">Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks</string> - <string name="customize_home_summary">Vali kodulehe/avalehe read ja pildid</string> - <string name="favorite_items_title">Märgi %s lemmikuks</string> - <plurals name="minutes"> - <item quantity="one">%s minut</item> - <item quantity="other">%s minutit</item> - </plurals> - <string name="purple">Purpurpunane</string> - <string name="orange">Oranž</string> - <string name="bold_blue">Tugev sinine</string> - <string name="black">Must</string> - <string name="skip_ignore">Eira</string> - <string name="skip_automatically">Jäta automaatselt vahele</string> - <string name="skip_ask">Vahelejätmiseks küsi</string> - <string name="ffmpeg_fallback">Kui ühtegi kaasnevat dekoodrit pole, siis kasuta vaid FFmpeg-i</string> - <string name="ffmpeg_prefer">Eelista kaasnevatele dekoodritele FFmpeg-i</string> - <string name="ffmpeg_never">Ära iialgi kasuta dekoderimiseks FFmpeg-i</string> - <string name="next_up_playback_end">Taasesituse lõpus</string> - <string name="white">Valge</string> - <string name="light_gray">Helehall</string> - <string name="dark_gray">Tumehall</string> - <string name="yellow">Kollane</string> - <string name="cyan">Rohekassinine</string> - <string name="magenta">Fuksiapunane</string> - <string name="subtitle_edge_outline">Äärisjoon</string> - <string name="subtitle_edge_shadow">Vari</string> - <string name="background_style_wrap">Reamurdmine</string> - <string name="prefer_mpv">Eelista MPV-d</string> - <string name="player_backend_options_subtitles_prefer_mpv">HDR-i taasesituseks kasuta ExoPlayerit</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">Ruut (1:1)</string> - <string name="image_type_thumb">Sõrmejälg</string> - <string name="image_type_primary">Esmane</string> - <string name="backdrop_style_dynamic">Pilt dünaamiliste värvidega</string> - <string name="backdrop_style_image">Ainult pilt</string> - <string name="enter_url_api_key"><![CDATA[Sisesta võrguaadress ja API võti]]></string> - <string name="api_key">API võti</string> - <string name="seerr_login">Logi sisse Seerri serverisse</string> - <string name="seerr_jellyfin_user">Jellyfini kasutaja</string> - <string name="seerr_local_user">Kohalik kasutaja</string> - <string name="search_and_download_subtitles"><![CDATA[Subtiitrite otsing ja allalaadimine]]></string> - <string name="no_subtitles_found">Kaugseadmest ei leidnunud subtiitreid</string> - <string name="in_app_screensaver">Kasuta rakendusesisest ekraanisäästjat</string> - <string name="delete_item">Kas sa oled kindel, et soovid selle objekti kustutada?</string> - <string name="show_media_management">Näida meediumi haldamise valikuid</string> - <string name="actor">Näitleja</string> - <string name="composer">Helilooja</string> - <string name="writer">Kirjanik</string> - <string name="guest_star">Külalisesineja</string> - <string name="producer">Tootja</string> - <string name="conductor">Dirigent</string> - <string name="lyricist">Laulusõnade autor</string> - <string name="arranger">Seade autor</string> - <string name="engineer">Heliinsener</string> - <string name="mixer">Helimiksija</string> - <string name="creator">Looja</string> - <string name="artist">Kunstnik</string> - <string name="search_for">Otsi: %s</string> - <string name="select_all">Vali kõik</string> - <string name="background_style_boxed">Ruudustatud</string> - <string name="screensaver_settings">Ekraanisäästja seadistused</string> - <string name="start_screensaver">Käivita ekraanisäästja</string> - <string name="duration">Kestus</string> - <string name="photos">Fotod</string> - <string name="include_types">Kaasa tüübid</string> - <string name="content_scale_fit">Sobita</string> - <string name="content_scale_crop">Kadreeri</string> - <string name="content_scale_fill">Täida</string> - <string name="content_scale_fill_width">Täida laiuses</string> - <string name="content_scale_fill_height">Täida kõrguses</string> - <string name="display_preset_default">Wolfskini vaikeseadistus</string> - <string name="display_preset_compact">Wolfskini kompaktne vaade</string> - <string name="display_preset_series_thumb">Sarja pisipildid</string> - <string name="display_preset_episode_thumbnails">Osa pisipildid</string> - <string name="volume_lowest">Vaikseim</string> - <string name="volume_low">Vaikne</string> - <string name="volume_medium">Keskmine</string> - <string name="volume_high">Vali</string> - <string name="volume_full">Täisvaljus</string> - <string name="start_after">Käivita peale</string> - <string name="animate">Animeeri</string> - <string name="max_age_rating">Ülemine vanusepiirang</string> - <string name="no_max">Puudub</string> - <string name="for_all_ages">Sobilik kõikidele vanustele</string> - <string name="up_to_age">Kuni vanuseni %s</string> - <string name="screensaver">Ekraanisäästja</string> - <string name="next_up_outro">Lõputiitrite ja lõpuklipi ajal</string> - <string name="series_continueing">Jätkuv sari</string> - <string name="include_types_summary">Mis tüüpi objekte peaks piltide kontekstis näitama</string> - <string name="albums">Albumid</string> - <string name="artists">Esitajad</string> - <string name="songs">Lood</string> - <string name="go_to_artist">Mine esitaja juurde</string> - <string name="now_playing">Hetkel esitamisel</string> - <string name="instant_mix">Kiirmiks</string> - <string name="go_to_album">Mine albumi juurde</string> - <string name="add_to_queue">Lisa esitusjärjekorda</string> - <string name="album_artist">Albumi esitaja</string> - <string name="album">Album</string> - <string name="popular_songs">Populaarsed lood</string> - <string name="play_next">Esita järgmisena</string> - <string name="music_videos">Muusikavideod</string> - <string name="lyrics">Laulusõnad</string> - <string name="hide_lyrics">Peida laulusõnad</string> - <string name="show_lyrics">Näita laulusõnu</string> - <string name="song_has_lyrics">Laulusõnad on olemas</string> - <string name="remove_from_queue">Eemalda esitusjärjekorrast</string> - <string name="show_album_cover">Näita albumi kaanepilti</string> - <string name="show_visualizer">Näita visualiseerijat</string> - <string name="show_backdrop">Näita tausta</string> - <string name="play_as_type">Kas esitad kui?</string> - <string name="visualizer_rationale">Hetkel esitamisel heliriba visualiseerimine toimib, kui rakendusel on õigus heli salvestada.</string> - <string name="continue_string">Jätka</string> - <string name="separate_types">Eraldi tüübid</string> - <string name="prefer_logos">Pealkirjade puhul eelista logode kuvamist</string> - <string name="updated_toast">Wolfskin on uuendatud versioonini %s</string> - <string name="remove_continue_watching">Eemalda „Jätka vaatamist“ valikust</string> - <string name="remove_next_up">Eemalda sari valikust „Järgmisena esitamisel“</string> - <string name="view_removed_next_up">Vaata sarja, mis sai eemaldatud valikust „Järgmisena esitamisel“</string> - <string name="view_more">Vaata veel</string> - <string name="discover_tv">Leia telesaateid</string> - <string name="discover_movies">Leia filme</string> - <string name="studios_in">Stuudiod: %1$s</string> - <string name="last_played">Viimati esitatud</string> - <string name="ass_subtitle_mode_libass">Otseesitus libass teegiga</string> - <string name="ass_subtitle_mode_exoplayer">Otseesitus sisseehitatud ExoPlayeri teegiga</string> - <string name="ass_subtitle_mode_transcode">Kodeeri serveris</string> - <string name="ass_subtitle_playback">SSA/ASS subtiitrite esitus</string> - <string name="next_up_never">Mitte kunagi</string> - <string name="external_player">Väline meediapleier</string> - <string name="system_default">Süsteemi vaikeväärtus</string> - <string name="cinema_mode">Kinovaade</string> - <string name="show_more">Näita veel: %1$s tk</string> - <string name="skip_behavior">Käitumine vahelejätmisel</string> - <string name="skip_behavior_summary">Sissejuhatuste, lõputiitrite jmt jaoks</string> - <string name="play_with">Esita rakendusega</string> - <string name="transcoding">Teisendan</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="combined_search_results">Kombineeritud otsingutulemused</string> - <string name="combined_search_results_on">Näita kõiki tulemusi ühe loendina</string> - <string name="combined_search_results_off">Näita tulemusi kategooria alusel</string> - <string name="results">Tulemused</string> - <string name="continue_watching_click_behavior">Jätka vaatamist / järgmisena esitamisel - käitumine klõpsimisel</string> - <string name="continue_watching_click_summary_on">Klõpsimisel esita</string> - <string name="continue_watching_click_summary_off">Klõpsimisel näita üksikasju</string> - <string name="require_login">Eelda profiili jaoks sisselogimist</string> - <string name="display_toggles_title">Hinnangute kuvamine</string> - <string name="display_toggles_summary">Kohenda kuvatavat</string> - <string name="profile_protection">Profiili kaitse</string> - <string name="stars">Tärne</string> - <string name="tomatoes">Tomateid</string> - <string name="show_voice_search_button">Näita häälotsingu nuppu</string> - <string name="visible_ui">Nähtav</string> - <string name="hidden_ui">Peidetud</string> - <string name="user_interface_language">Kasutajaliidese keel</string> - <string name="view_options_type_grid">Ruudustik</string> - <string name="view_options_type_list">Loend</string> - <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> - <string name="login_credentials_expired">Autentimiseandmed on aegunud, palun logi uuesti sisse</string> - <string name="login_not_authorized">Sul pole hetkel õigusi ligipääsuks sellele serverile</string> - <string name="prefer_dovi_over_hdr10_plus">HDR10+ asemel eelista Dolby Visionit</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest</string> - <string name="max_resolution">Maksimaalne resolutsioon enne kodeeringu muutmist</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d osa</item> - <item quantity="other">%1$d osa</item> - </plurals> - <string name="top_rated">Parima hinnanguga</string> - <string name="hdr_formats">HDR-vormingud</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="server_version_required">Vajalik serveriversioon: %1$s</string> - <string name="server_version_not_supported_future">Serveriversioon ei saa lähitulevikus olema toetatud</string> - <string name="user">Kasutaja</string> - <string name="series">Sai</string> - <string name="load_from_server_plugin">Vaikimisi laadi serverist</string> - <string name="save_to_local">Salvesta vaid siia seadmesse</string> - <string name="home_settings_source_unset">Vaikimisi</string> - <string name="home_settings_source_local">Seadmekohane</string> - <string name="home_settings_source_server_profile">Kasutajaprofiil serveris</string> - <string name="home_settings_source_plugin">Vaikimisi väärtus serveris</string> - <string name="home_settings_source_web">Kasutaja veebiklient</string> - <string name="chosen_by">Valija: %1$s</string> - <string name="force_enable">Jõusta sundkorras</string> - <string name="force_disable">Lõpeta sundkorras jõustamine</string> - <string name="managed_by_server">Hallatud serveri poolt</string> - <string name="error">Viga</string> - <string name="convert_dovi_profile_7">Teisenda Dolby Vision Profile 7 → 8.1</string> - <string name="convert_dovi_profile_7_summary">Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7</string> - <string name="artist_source">Esitaja allikas</string> </resources> diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-fi/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> -</resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index 85603d28..00000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,785 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">À propos</string> - <string name="add_server">Ajouter un serveur</string> - <string name="add_user">Ajouter un utilisateur</string> - <string name="audio">Audio</string> - <string name="cancel_recording">Annuler l\'enregistrement</string> - <string name="cancel">Annuler</string> - <string name="confirm">Confirmer</string> - <string name="continue_watching">Continuer à regarder</string> - <string name="delete">Supprimer</string> - <string name="director">Réalisateur</string> - <string name="directed_by">Réalisé par %1$s</string> - <string name="episodes">Épisodes</string> - <string name="active_recordings">Enregistrements en cours</string> - <string name="birthplace">Lieu de naissance</string> - <string name="born">Né(e)</string> - <string name="cancel_series_recording">Annuler l’enregistrement de la série</string> - <string name="chapters">Chapitres</string> - <string name="clear_image_cache">Vider le cache des images</string> - <string name="collection">Collection</string> - <string name="collections">Collections</string> - <string name="commercial">Publicité</string> - <string name="community_rating">Note de la communauté</string> - <string name="compose_error_message_title">Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur.</string> - <string name="critic_rating">Note des critiques</string> - <string name="decimal_seconds">%.2f secondes</string> - <string name="died">Décédé(e)</string> - <string name="disabled">Désactivé</string> - <string name="downloading">Téléchargement en cours…</string> - <string name="enabled">Activé</string> - <string name="enter_server_url">Saisir l’IP ou l’URL du serveur</string> - <string name="external_track">Externe</string> - <string name="favorites">Favoris</string> - <string name="file_size">Taille</string> - <string name="forced_track">Forcé</string> - <string name="genres">Genres</string> - <string name="go_to_series">Voir la série</string> - <string name="go_to">Aller à</string> - <string name="hide">Masquer</string> - <string name="home">Accueil</string> - <string name="immediate">Immédiat</string> - <string name="library">Bibliothèque</string> - <string name="live_tv">TV en direct</string> - <string name="loading">Chargement en cours…</string> - <string name="mark_entire_series_as_played">Marquer toute la série comme vue ?</string> - <string name="mark_entire_series_as_unplayed">Marquer toute la série comme non vue ?</string> - <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_title">Films</string> - <string name="name">Nom</string> - <string name="next_up">À suivre</string> - <string name="no_data">Aucune donnée</string> - <string name="no_results">Aucun résultat</string> - <string name="no_scheduled_recordings">Aucun enregistrement programmé</string> - <string name="no_update_available">Aucune mise à jour disponible</string> - <string name="none">Aucun</string> - <string name="outro">Générique de fin</string> - <string name="path">Chemin</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> - <string name="playback">Lecture</string> - <string name="playlist">Liste de lecture</string> - <string name="playlists">Listes de lecture</string> - <string name="preview">Aperçu</string> - <string name="profile_specific_settings">Paramètres du profil utilisateur</string> - <string name="queue">File d’attente</string> - <string name="recap">Récapitulatif</string> - <string name="recently_added_in">Récemment ajoutés dans %1$s</string> - <string name="recently_added">Récemment ajouté</string> - <string name="recently_recorded">Récemment enregistré</string> - <string name="recently_released">Récemment sorti</string> - <string name="recommended">Recommandé</string> - <string name="record_program">Enregistrer le programme</string> - <string name="record_series">Enregistrer la série</string> - <string name="remove_favorite">Retirer des favoris</string> - <string name="restart">Redémarrer</string> - <string name="resume">Reprendre</string> - <string name="save">Enregistrer</string> - <string name="search">Rechercher</string> - <string name="searching">Recherche en cours…</string> - <string name="select_server">Sélectionner un serveur</string> - <string name="select_user">Sélectionner un utilisateur</string> - <string name="show_debug_info">Afficher les infos de débogage</string> - <string name="skip">Passer</string> - <string name="sort_by_date_added">Date d’ajout</string> - <string name="sort_by_date_episode_added">Date d’ajout de l’épisode</string> - <string name="sort_by_date_played">Date de lecture</string> - <string name="sort_by_date_released">Date de sortie</string> - <string name="sort_by_name">Nom</string> - <string name="sort_by_random">Aléatoire</string> - <string name="studios">Studios</string> - <string name="subtitle">Sous-titre</string> - <string name="subtitles">Sous-titres</string> - <string name="suggestions">Suggestions</string> - <string name="switch_servers">Changer de serveur</string> - <string name="switch_user">Changer</string> - <plurals name="trailers"> - <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_title">Séries TV</string> - <string name="unknown">Inconnu</string> - <string name="updates">Mises à jour</string> - <string name="version">Version</string> - <string name="video">Vidéo</string> - <string name="videos">Vidéos</string> - <string name="watch_live">Regarder en direct</string> - <string name="play_with_transcoding">Lire avec transcodage</string> - <string name="show_clock">Afficher l’horloge</string> - <string name="add_favorite">Ajouter aux favoris</string> - <string name="choose_stream">Choisir %1$s</string> - <string name="intro">Générique d’introduction</string> - <string name="create_playlist">Créer une nouvelle liste de lecture</string> - <string name="add_to_playlist">Ajouter à la liste de lecture</string> - <string name="one_click_pause">Pause en un clic</string> - <string name="font">Police</string> - <string name="background">Arrière-plan</string> - <string name="success">Succès</string> - <string name="runtime_sort">Durée</string> - <plurals name="other_extras"> - <item quantity="one">Autre</item> - <item quantity="many">Autres</item> - <item quantity="other">Autres</item> - </plurals> - <plurals name="clips"> - <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">Scène supprimé</item> - <item quantity="other">Scènes supprimées</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Interviews</item> - <item quantity="many">Interview</item> - <item quantity="other">Interviews</item> - </plurals> - <plurals name="scenes"> - <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">É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> - <string name="advanced_ui">Interface avancée</string> - <string name="app_theme">Thème de l’application</string> - <string name="auto_check_for_updates">Vérifier automatiquement les mises à jour</string> - <string name="check_for_updates">Vérifier les mises à jour</string> - <string name="combine_continue_next_summary">S’applique uniquement aux séries TV</string> - <string name="default_track">Par défaut</string> - <string name="discovered_servers">Serveurs découverts</string> - <string name="error_loading_collection">Erreur de chargement de la collection %1$s</string> - <string name="hide_controller_timeout">Masquer les commandes de lecture</string> - <string name="hide_debug_info">Masquer les infos de débogage</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="license_info">Informations sur la licence</string> - <string name="more_like_this">Plus de ce genre</string> - <string name="more">Plus</string> - <string name="play_count">Nombre de lectures</string> - <string name="playback_debug_info">Afficher les infos de débogage de lecture</string> - <string name="show">Afficher</string> - <string name="shuffle">Mélanger</string> - <string name="submit">Soumettre</string> - <string name="tv_guide">Guide</string> - <string name="ui_interface">Interface</string> - <string name="video_scale">Échelle de la vidéo</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_title">Bonus</string> - <plurals name="behind_the_scenes"> - <item quantity="one">Dans les coulisses</item> - <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> - <string name="direct_play_ass">Lire directement les sous-titres ASS</string> - <string name="direct_play_pgs">Lire directement les sous-titres PGS</string> - <string name="downmix_stereo">Toujours mixer en stéréo</string> - <string name="ffmpeg_extension_pref">Utiliser le module de décodage FFmpeg</string> - <string name="global_content_scale">Échelle de contenu par défaut</string> - <string name="install_update">Installer la mise à jour</string> - <string name="installed_version">Version installée</string> - <string name="max_homepage_items">Éléments sur les lignes de la page d\'accueil</string> - <string name="nav_drawer_pins_summary">Choisissez les éléments à afficher par défaut, les autres seront masqués</string> - <string name="nav_drawer_pins">Personnaliser les éléments du menu de navigation</string> - <string name="nav_drawer_switch_on_focus_summary_off">Cliquez pour changer de page</string> - <string name="nav_drawer_switch_on_focus">Changer de page du menu de navigation au focus</string> - <string name="pass_out_protection">Protection contre l’inactivité</string> - <string name="remember_selected_tab">Se souvenir des onglets sélectionnés</string> - <string name="rewatch_next_up">Activer le re-visionnage dans \"À suivre\"</string> - <string name="seek_bar_steps">Étapes de la barre de recherche</string> - <string name="send_app_logs_summary">Utile pour le débogage</string> - <string name="send_app_logs">Envoyer les logs de l’appli au serveur actuel</string> - <string name="send_crash_reports_summary">Tentera d’envoyer au dernier serveur connecté</string> - <string name="send_crash_reports">Envoyer les rapports de plantage</string> - <string name="settings">Paramètres</string> - <string name="skip_back_preference">Revenir en arrière</string> - <string name="skip_forward_preference">Avancer</string> - <string name="update_available">Mise à jour disponible</string> - <string name="update_url_summary">URL utilisée pour vérifier les mises à jour de l’appli</string> - <string name="update_url">Mettre à jour l\'URL</string> - <string name="font_size">Taille de la police</string> - <string name="font_color">Couleur de la police</string> - <string name="font_opacity">Opacité de la police</string> - <string name="edge_style">Style des contours</string> - <string name="edge_color">Couleur des contours</string> - <string name="background_opacity">Opacité de l\'arrière-plan</string> - <string name="background_style">Style de l\'arrière-plan</string> - <string name="subtitle_style">Style des sous-titres</string> - <string name="background_color">Couleur de fond</string> - <string name="reset">Réinitialiser</string> - <string name="bold_font">Police en gras</string> - <string name="player_backend">Moteur de lecture</string> - <string name="mpv_hardware_decoding">MPV : Utiliser le décodage matériel</string> - <string name="disable_if_crash">Désactiver en cas de plantage</string> - <string name="mpv_options">Options MPV</string> - <string name="exoplayer_options">Options ExoPlayer</string> - <string name="skip_segment_unknown">Passer le segment</string> - <string name="skip_segment_commercial">Passer les publicités</string> - <string name="skip_segment_preview">Passer l\'aperçu</string> - <string name="skip_segment_recap">Passer le récapitulatif</string> - <string name="skip_segment_outro">Passer le générique de fin</string> - <string name="skip_segment_intro">Passer l\'intro</string> - <string name="played">Lu</string> - <string name="filter">Filtre</string> - <string name="year">Année</string> - <string name="decade">Décennie</string> - <string name="remove">Retirer</string> - <string name="bitrate">Débit</string> - <string name="show_next_up_when">Afficher \"À suivre\"</string> - <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">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> - <item quantity="many">%d heures</item> - <item quantity="other">%d heures</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s téléchargement</item> - <item quantity="many">%s téléchargements</item> - <item quantity="other">%s téléchargements</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d élément</item> - <item quantity="many">%d éléments</item> - <item quantity="other">%d éléments</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d seconde</item> - <item quantity="many">%d secondes</item> - <item quantity="other">%d secondes</item> - </plurals> - <string name="play_theme_music">Lire la musique de thème</string> - <string name="skip_back_on_resume_preference">Revenir en arrière à la reprise de la lecture</string> - <string name="skip_commercials_behavior">Comportement de saut des publicités</string> - <string name="skip_intro_behavior">Comportement de saut de l\'intro</string> - <string name="skip_outro_behavior">Comportement de saut du générique de fin</string> - <string name="skip_previews_behavior">Comportement de saut des aperçus</string> - <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">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">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> - <string name="search_and_download"><![CDATA[Rechercher & Télécharger]]></string> - <string name="tv_dvr_schedule">Calendrier du DVR</string> - <string name="aired_episode_order">Trié par date de diffusion</string> - <string name="one_click_pause_summary_on">Appuyez au centre de la croix directionnelle pour mettre en pause/reprendre</string> - <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> - <string name="playback_overrides">Remplacer la lecture</string> - <string name="subtitle_margin">Marge</string> - <string name="mpv_use_gpu_next">MPV: Utiliser gpu-next</string> - <string name="mpv_conf">Éditer mpv.conf</string> - <string name="discard_change">Annuler les modifications ?</string> - <string name="verbose_logging">Journalisation détaillée</string> - <string name="enter_pin">Saisir le code PIN</string> - <string name="sign_in_auto">Connexion automatique</string> - <string name="use_server_credentials">Connexion via serveur</string> - <string name="press_enter_to_confirm">Appuyez au centre pour confirmer</string> - <string name="require_pin_code">Code PIN requis pour le profil</string> - <string name="confirm_pin">Confirmer le code PIN</string> - <string name="incorrect">Erroné</string> - <string name="pin_too_short">Le code PIN doit comporter au moins 4 chiffres</string> - <string name="will_remove_pin">Supprimera le code PIN</string> - <string name="image_cache_size">Taille du cache disque de l\'image (Mo)</string> - <string name="view_options">Options d\'affichage</string> - <string name="columns">Colonnes</string> - <string name="spacing">Espacement</string> - <string name="aspect_ratio">Format d\'image</string> - <string name="show_details">Afficher les détails</string> - <string name="cast_and_crew"><![CDATA[Distribution et équipe]]></string> - <string name="guest_stars">Invités spéciaux</string> - <string name="edge_size">Taille des bordures</string> - <string name="refresh_rate_switching">Changement de fréquence de rafraîchissement</string> - <string name="automatic">Automatique</string> - <string name="username_or_password">Utiliser un nom d\'utilisateur/mot de passe</string> - <string name="login">Connexion</string> - <string name="show_titles">Afficher les titres</string> - <string name="image_type">Type d\'image</string> - <string name="media_information">Informations sur le média</string> - <string name="general">Général</string> - <string name="container">Conteneur</string> - <string name="title">Titre</string> - <string name="codec">Codec</string> - <string name="profile">Profil</string> - <string name="level">Niveau</string> - <string name="resolution">Résolution</string> - <string name="anamorphic">Anamorphique</string> - <string name="interlaced">Entrelacé</string> - <string name="framerate">Fréquence d\'images</string> - <string name="bit_depth">Profondeur de bits</string> - <string name="video_range">Plage vidéo</string> - <string name="video_range_type">Type de plage vidéo</string> - <string name="color_space">Espace colorimétrique</string> - <string name="color_transfer">Transfert des couleurs</string> - <string name="pixel_format">Format de pixel</string> - <string name="language">Langue</string> - <string name="layout">Mise en page</string> - <string name="channels">Chaînes</string> - <string name="sample_rate">Taux d’échantillonnage</string> - <string name="avc">AVC</string> - <string name="yes">Oui</string> - <string name="no">Non</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="live_tv_repeat">Répéter</string> - <string name="favorite_channels_at_beginning">Afficher les chaînes favorites en premier</string> - <string name="nal">NAL</string> - <string name="hlg">HLG</string> - <string name="color_primaries">Couleurs primaires</string> - <string name="ref_frames">Images de référence</string> - <string name="sort_channels_recently_watched">Trier les chaînes par date de visionnage</string> - <string name="color_code_programs">Classer les programmes par code couleur</string> - <string name="ends_at">Terminera à %1$s</string> - <string name="enter_server_address">Entrez l\'adresse du serveur</string> - <string name="no_servers_found">Aucun serveur trouvé</string> - <string name="subtitle_delay">Décalage des sous-titres</string> - <string name="backdrop_display">Style d\'arrière-plan</string> - <string name="clear_track_choices">Effacer les choix de piste</string> - <string name="resolution_switching">Changement de résolution</string> - <string name="local">Local</string> - <string name="play_trailer">Lire la bande-annonce</string> - <string name="no_trailers">Pas de bandes-annonces</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="only_forced_subtitles">Sous-titres forcés uniquement</string> - <string name="force_dovi_profile_7">Lecture directe Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">Ignore les vérifications de compatibilité des appareils</string> - <string name="voice_search">Recherche vocale</string> - <string name="voice_starting">Démarrage</string> - <string name="voice_search_prompt">Parler pour rechercher</string> - <string name="processing">Traitement</string> - <string name="press_back_to_cancel">Appuyez sur Retour pour annuler</string> - <string name="voice_error_audio">Erreur d\'enregistrement audio</string> - <string name="voice_error_client">Erreur de reconnaissance vocale</string> - <string name="voice_error_permissions">Autorisation du microphone requise</string> - <string name="voice_error_network">Erreur réseau</string> - <string name="voice_error_network_timeout">Délai d\'expiration du réseau</string> - <string name="voice_error_no_match">Aucune voix reconnue</string> - <string name="voice_error_busy">Reconnaissance vocale occupée</string> - <string name="voice_error_server">Erreur serveur</string> - <string name="voice_error_speech_timeout">Aucune voix détectée</string> - <string name="voice_error_unknown">Erreur inconnue</string> - <string name="voice_error_start_failed">Échec du démarrage de la reconnaissance vocale</string> - <string name="voice_error_timeout">Délai d\'attente de la reconnaissance vocale dépassé</string> - <string name="retry">Réessayer</string> - <string name="discover">Découvrir</string> - <string name="request">Demande</string> - <string name="pending">En attente</string> - <string name="seerr_integration">Intégration Seerr</string> - <string name="remove_seerr_server">Supprimer le serveur Seerr</string> - <string name="seerr_server_added">Serveur Seerr ajouté</string> - <string name="password">Mot de passe</string> - <string name="username">Nom d\'utilisateur</string> - <string name="url">URL</string> - <string name="trending">Tendances</string> - <string name="upcoming_movies">Films à venir</string> - <string name="upcoming_tv">Séries à venir</string> - <string name="request_4k">Demande en 4K</string> - <string name="software_decoding_av1">Décodage logiciel AV1</string> - <string name="upgrade_mpv_toast">MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres.</string> - <string name="hdr_subtitle_style">Style de sous-titres HDR</string> - <string name="image_subtitle_opacity">Opacité du sous-titre de l\'image</string> - <string name="brightness">Luminosité</string> - <string name="contrast">Contraste</string> - <string name="saturation">Saturation</string> - <string name="hue">Teinte</string> - <string name="red">Rouge</string> - <string name="green">Vert</string> - <string name="blue">Bleu</string> - <string name="blur">Flou</string> - <string name="save_for_album">Enregistrer pour l\'album</string> - <string name="play_slideshow">Lire le diaporama</string> - <string name="stop_slideshow">Arrêter le diaporama</string> - <string name="slideshow_at_beginning">Au début</string> - <string name="no_more_images">Plus de photos</string> - <string name="rotate_left">Faire pivoter à gauche</string> - <string name="rotate_right">Faire pivoter à droite</string> - <string name="zoom_in">Zoomer</string> - <string name="zoom_out">Dézoomer</string> - <string name="slideshow_duration">Durée du diaporama</string> - <string name="play_videos_during_slideshow">Diffuser des vidéos pendant le diaporama</string> - <plurals name="days"> - <item quantity="one">%s jour</item> - <item quantity="many">%s jours</item> - <item quantity="other">%s jours</item> - </plurals> - <string name="quick_connect">Connexion rapide</string> - <string name="quick_connect_summary">Autoriser un autre appareil à se connecter à votre compte</string> - <string name="quick_connect_code">Entrez le code de connexion rapide</string> - <string name="quick_connect_code_error">Le code doit comporter 6 chiffres</string> - <string name="quick_connect_success">Appareil autorisé avec succès</string> - <string name="send_media_info_log_to_server">Envoyer le journal des informations multimédias au serveur</string> - <string name="no_limit">Aucune limite</string> - <string name="max_days_next_up">Nombre maximal de jours dans Prochainement</string> - <string name="add_row">Ajouter une ligne</string> - <string name="genres_in">Genres en %1$s</string> - <string name="recently_released_in">Récemment publié en %1$s</string> - <string name="height">Hauteur</string> - <string name="apply_all_rows">S\'applique à toutes les lignes</string> - <string name="customize_home">Personnaliser la page d\'accueil</string> - <string name="home_rows">Lignes d\'accueil</string> - <string name="load_from_server">Charger à partir du profil utilisateur du serveur</string> - <string name="save_to_server">Enregistrer dans le profil utilisateur du serveur</string> - <string name="load_from_web_client">Charger à partir du client Web</string> - <string name="use_series">Utiliser l\'image de la série</string> - <string name="add_row_for">Ajouter une ligne pour %1$s</string> - <string name="overwrite_server_settings">Écraser les paramètres sur le serveur ?</string> - <string name="overwrite_local_settings">Écraser les paramètres locaux ?</string> - <string name="for_episodes">Pour les épisodes</string> - <string name="suggestions_for">Suggestions pour %1$s</string> - <string name="increase_all_cards_size">Augmenter la taille pour toutes les cartes</string> - <string name="decrease_all_cards_size">Diminuer la taille pour toutes les cartes</string> - <string name="use_thumb_images">Utiliser des miniatures</string> - <string name="settings_saved">Paramètres enregistrés</string> - <string name="display_presets">Afficher les préréglages</string> - <string name="display_presets_description">Préréglages intégrés pour styliser rapidement toutes les lignes</string> - <string name="customize_home_summary">Choisissez des lignes et des images sur la page d\'accueil</string> - <string name="favorite_items_title">Favori %s</string> - <plurals name="minutes"> - <item quantity="one">%s minute</item> - <item quantity="many">%s minutes</item> - <item quantity="other">%s minute</item> - </plurals> - <string name="start_after">Démarrer après</string> - <string name="animate">Animer</string> - <string name="max_age_rating">Classification par âge maximale</string> - <string name="no_max">Pas de maximum</string> - <string name="for_all_ages">Pour tous les âges</string> - <string name="up_to_age">Jusqu\'à l\'âge de %s</string> - <string name="screensaver">Écran de veille</string> - <string name="screensaver_settings">Paramètres de l\'économiseur d\'écran</string> - <string name="start_screensaver">Lancer l\'économiseur d\'écran</string> - <string name="duration">Durée</string> - <string name="photos">Photos</string> - <string name="include_types">Inclure les types</string> - <string name="include_types_summary">Quels types d\'éléments afficher des images pour</string> - <string name="content_scale_fit">Ajustement</string> - <string name="content_scale_crop">Recadrer</string> - <string name="content_scale_fill">Remplir</string> - <string name="content_scale_fill_width">Largeur de remplissage</string> - <string name="content_scale_fill_height">Hauteur de remplissage</string> - <string name="display_preset_default">Wolfskin Par défaut</string> - <string name="display_preset_compact">Wolfskin Compact</string> - <string name="display_preset_series_thumb">Images miniatures de la série</string> - <string name="display_preset_episode_thumbnails">Images miniatures de l\'épisode</string> - <string name="volume_lowest">Le plus bas</string> - <string name="volume_low">Faible</string> - <string name="volume_medium">Moyen</string> - <string name="volume_high">Haut</string> - <string name="volume_full">Maximum</string> - <string name="purple">Violet</string> - <string name="orange">Orange</string> - <string name="bold_blue">Bleu vif</string> - <string name="black">Noir</string> - <string name="skip_ignore">Ignorer</string> - <string name="skip_automatically">Passer automatiquement</string> - <string name="skip_ask">Demander à passer</string> - <string name="ffmpeg_fallback">N\'utiliser FFmpeg que si aucun décodeur intégré n\'existe</string> - <string name="ffmpeg_prefer">Préférer l\'utilisation de FFmpeg plutôt que les décodeurs intégrés</string> - <string name="ffmpeg_never">Ne jamais utiliser les décodeurs FFmpeg</string> - <string name="next_up_playback_end">À la fin de la lecture</string> - <string name="next_up_outro">Pendant le générique de fin/outro</string> - <string name="white">Blanc</string> - <string name="light_gray">Gris clair</string> - <string name="dark_gray">Gris foncé</string> - <string name="yellow">Jaune</string> - <string name="cyan">Cyan</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contour</string> - <string name="subtitle_edge_shadow">Ombre</string> - <string name="background_style_wrap">Envelopper</string> - <string name="background_style_boxed">Encadré</string> - <string name="prefer_mpv">Préférer MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Utiliser ExoPlayer pour la lecture HDR</string> - <string name="aspect_ratios_poster">Affiche (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">Carré (1:1)</string> - <string name="image_type_primary">Primaire</string> - <string name="image_type_thumb">Miniature</string> - <string name="backdrop_style_dynamic">Image avec couleur dynamique</string> - <string name="backdrop_style_image">Image uniquement</string> - <string name="enter_url_api_key"><![CDATA[Saisir l\'URL et la clé API]]></string> - <string name="api_key">Clé API</string> - <string name="seerr_login">Se connecter au serveur Seerr</string> - <string name="seerr_jellyfin_user">Utilisateur Jellyfin</string> - <string name="seerr_local_user">Utilisateur local</string> - <string name="search_and_download_subtitles"><![CDATA[Rechercher et télécharger des sous-titres]]></string> - <string name="no_subtitles_found">Aucun sous-titre distant n\'a été trouvé</string> - <string name="series_continueing">En cours</string> - <string name="in_app_screensaver">Utiliser l\'économiseur d\'écran intégré</string> - <string name="actor">Acteur</string> - <string name="composer">Compositeur</string> - <string name="writer">Scénariste</string> - <string name="guest_star">Invité spécial</string> - <string name="producer">Producteur</string> - <string name="conductor">Chef d\'orchestre</string> - <string name="lyricist">Parolier</string> - <string name="arranger">Arrangeur</string> - <string name="engineer">Ingénieur</string> - <string name="mixer">Mixeur</string> - <string name="creator">Créateur</string> - <string name="artist">Artiste</string> - <string name="delete_item">Êtes-vous sûr de vouloir supprimer cet élément ?</string> - <string name="show_media_management">Afficher les options de gestion des médias</string> - <string name="search_for">Recherche %s</string> - <string name="select_all">Tout sélectionner</string> - <string name="albums">Albums</string> - <string name="artists">Artistes</string> - <string name="songs">Chansons</string> - <string name="go_to_artist">Voir l\'artiste</string> - <string name="now_playing">Lecture en cours</string> - <string name="instant_mix">Mix instantané</string> - <string name="go_to_album">Voir l\'album</string> - <string name="add_to_queue">Ajouter à la file</string> - <string name="album_artist">Artiste de l\'album</string> - <string name="album">Album</string> - <string name="popular_songs">Titres populaires</string> - <string name="play_next">Lire ensuite</string> - <string name="music_videos">Clips</string> - <string name="lyrics">Paroles</string> - <string name="hide_lyrics">Masquer les paroles</string> - <string name="show_lyrics">Afficher les paroles</string> - <string name="song_has_lyrics">Contient des paroles</string> - <string name="remove_from_queue">Retirer de la file</string> - <string name="show_album_cover">Afficher la pochette</string> - <string name="show_visualizer">Afficher le visualiseur</string> - <string name="show_backdrop">Afficher l\'arrière-plan</string> - <string name="play_as_type">Qui écoute ?</string> - <string name="visualizer_rationale">L\'affichage du visualiseur nécessite l\'autorisation d\'enregistrer du contenu audio.</string> - <string name="continue_string">Suivant</string> - <string name="separate_types">Séparer par types</string> - <string name="prefer_logos">Préférer les logos pour les titres</string> - <string name="updated_toast">Wolfskin a été mis à jour vers la version %s</string> - <string name="view_more">Voir plus</string> - <string name="discover_tv">Parcourir les séries</string> - <string name="discover_movies">Parcourir les films</string> - <string name="remove_continue_watching">Supprimer de continuer à regarder</string> - <string name="remove_next_up">Supprimer la série de la liste suivante</string> - <string name="view_removed_next_up">Voir les séries supprimées de la liste suivante</string> - <string name="studios_in">Studios en %1$s</string> - <string name="next_up_never">Jamais</string> - <string name="last_played">Dernière lecture</string> - <string name="ass_subtitle_mode_libass">Lecture directe avec libass</string> - <string name="ass_subtitle_mode_exoplayer">Lecture directe avec ExoPlayer intégré</string> - <string name="ass_subtitle_mode_transcode">Incruster / Transcoder sur le serveur</string> - <string name="ass_subtitle_playback">Lecture des sous-titres SSA/ASS</string> - <string name="external_player">Lecteur externe</string> - <string name="system_default">Paramètres système par défaut</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="combined_search_results">Résultats de recherche combinés</string> - <string name="combined_search_results_on">Afficher tous les résultats dans une seule liste</string> - <string name="combined_search_results_off">Afficher les résultats par catégorie</string> - <string name="results">Résultats</string> - <string name="cinema_mode">Mode cinéma</string> - <string name="show_more">Afficher %1$s de plus</string> - <string name="skip_behavior">Comportement de saut</string> - <string name="skip_behavior_summary">Pour les génériques d\'ouverture, les génériques de fin, etc</string> - <string name="play_with">Jouer avec</string> - <string name="transcoding">Transcodage</string> - <string name="require_login">Connexion requise pour le profil</string> - <string name="show_voice_search_button">Afficher le bouton de recherche vocale</string> - <string name="visible_ui">Visible</string> - <string name="hidden_ui">Caché</string> - <string name="view_options_type_grid">Grille</string> - <string name="view_options_type_list">Liste</string> - <string name="view_options_type_list_compact">Liste compacte</string> - <string name="user_interface_language">Langue de l\'interface utilisateur</string> - <string name="display_toggles_title">Affichage des notes</string> - <string name="display_toggles_summary">Personnaliser les éléments à afficher</string> - <string name="profile_protection">Protection du profil</string> - <string name="continue_watching_click_behavior">Comportement de clic sur « Continuer à regarder »/« Suivant »</string> - <string name="continue_watching_click_summary_on">Jouez au clic</string> - <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> - <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">Saison %1$d</string> - <string name="disable_audio_offload">Désactiver le délestage matériel audio</string> - <string name="disable_audio_offload_summary">La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils</string> - <string name="prefer_dovi_over_hdr10_plus">Préférez Dolby Vision à HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+</string> - <string name="dim_after_delay">Écran en mode veille après un certain délai</string> - <string name="dim_percentage">Pourcentage de réduction de luminosité</string> - <string name="dim_screen">Écran sombre</string> - <string name="photo_albums">Albums photos</string> - <string name="no_favorites_found">Aucun favori trouvé !</string> - <string name="remove_from_playlist">Supprimer de la liste de lecture</string> - <string name="login_credentials_expired">Vos identifiants ont expiré. Veuillez vous reconnecter</string> - <string name="login_not_authorized">Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment</string> - <string name="max_resolution">Résolution maximale avant le transcodage</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Épisode</item> - <item quantity="many">%1$d Épisodes</item> - <item quantity="other">%1$d Épisode</item> - </plurals> - <string name="top_rated">Les mieux notés</string> - <string name="hdr_formats">Formats HDR</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="force_enable">Activation forcée</string> - <string name="force_disable">Désactivation forcée</string> - <string name="server_version_not_supported_future">La version du serveur ne sera bientôt plus prise en charge</string> - <string name="server_version_required">Version requise du serveur : %1$s</string> - <string name="chosen_by">Sélectionné par %1$s</string> - <string name="user">Utilisateur</string> - <string name="series">Séries</string> - <string name="load_from_server_plugin">Charger les paramètres par défaut depuis le serveur</string> - <string name="save_to_local">Enregistrer uniquement sur cet appareil</string> - <string name="home_settings_source_unset">Défaut</string> - <string name="home_settings_source_local">Spécifique à l\'appareil</string> - <string name="home_settings_source_server_profile">Profil utilisateur du serveur</string> - <string name="home_settings_source_plugin">Paramètres par défaut du serveur</string> - <string name="home_settings_source_web">Client Web utilisateur</string> - <string name="convert_dovi_profile_7">Convertir le profil Dolby Vision 7 en 8.1</string> - <string name="convert_dovi_profile_7_summary">Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7</string> - <string name="artist_source">Source de l\'artiste</string> - <string name="managed_by_server">Géré par le serveur</string> - <string name="error">Erreur</string> -</resources> diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml deleted file mode 100644 index 1eb5b58b..00000000 --- a/app/src/main/res/values-hr/strings.xml +++ /dev/null @@ -1,744 +0,0 @@ -<?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">Wolfskin zadani</string> - <string name="display_preset_compact">Wolfskin 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">Wolfskin 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> - <string name="remove_from_playlist">Ukloni s popisa za reprodukciju</string> - <string name="login_credentials_expired">Vjerodajnice su istekle, prijavite se ponovno</string> - <string name="login_not_authorized">Trenutno nemate ovlasti za pristup poslužitelju</string> -</resources> diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml deleted file mode 100644 index f078a5f7..00000000 --- a/app/src/main/res/values-hu/strings.xml +++ /dev/null @@ -1,647 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Névjegy</string> - <string name="add_favorite">Hozzáadás kedvencekhez</string> - <string name="add_server">Szerver hozzáadása</string> - <string name="add_user">Felhasználó hozzáadása</string> - <string name="audio">Hang</string> - <string name="birthplace">Születési hely</string> - <string name="bitrate">Bitráta</string> - <string name="born">Született</string> - <string name="cancel">Mégse</string> - <string name="chapters">Fejezetek</string> - <string name="choose_stream">%1$s kiválasztása</string> - <string name="clear_image_cache">Kép-gyorsítótár törlése</string> - <string name="collection">Gyűjtemény</string> - <string name="collections">Gyűjtemények</string> - <string name="community_rating">Közösségi értékelés</string> - <string name="compose_error_message_title">Hiba történt! Nyomja meg a gombot, hogy elküldje a naplófájlokat a szervernek.</string> - <string name="confirm">Megerősít</string> - <string name="critic_rating">Kritikusi értékelés</string> - <string name="decimal_seconds">%.2f másodperc</string> - <string name="default_track">Alapértelmezett</string> - <string name="delete">Törlés</string> - <string name="directed_by">Rendezte: %1$s</string> - <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 é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> - <string name="episodes">Epizódok</string> - <string name="error_loading_collection">Hiba a következő gyűjtemény betöltése során: %1$s</string> - <string name="external_track">Külső</string> - <string name="favorites">Kedvencek</string> - <string name="active_recordings">Aktív felvételek</string> - <string name="cancel_recording">Felvétel leállítása</string> - <string name="cancel_series_recording">Sorozatfelvétel leállítása</string> - <string name="commercial">Reklám</string> - <string name="continue_watching">Megtekintés folytatása</string> - <string name="died">Elhunyt</string> - <string name="file_size">Méret</string> - <string name="forced_track">Kiegészítő</string> - <string name="genres">Műfajok</string> - <string name="go_to_series">Ugrás a sorozathoz</string> - <string name="go_to">Ugrás ide</string> - <string name="hide_controller_timeout">Lejátszási gombok elrejtése</string> - <string name="hide_debug_info">Hibakeresési infó elrejtése</string> - <string name="hide">Elrejt</string> - <string name="home">Kezdőlap</string> - <string name="immediate">Azonnal</string> - <string name="intro">Intró</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Könyvtár</string> - <string name="license_info">Lincenc információk</string> - <string name="live_tv">Élő TV</string> - <string name="loading">Betöltés…</string> - <string name="mark_entire_series_as_played">Megjelöli az egész sorozatot megnézettként?</string> - <string name="mark_entire_series_as_unplayed">Megjelöli az egész sorozatot megnézetlenként?</string> - <string name="mark_unwatched">Jelölés megnézetlenként</string> - <string name="mark_watched">Jelölés megnézettként</string> - <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_title">Filmek</string> - <string name="name">Név</string> - <string name="next_up">Következő</string> - <string name="no_data">Nincs adat</string> - <string name="no_results">Nincs találat</string> - <string name="no_scheduled_recordings">Nincs időzített felvétel</string> - <string name="no_update_available">Nincs elérhető frissítés</string> - <string name="none">Nincs</string> - <string name="outro">Stáblista</string> - <string name="path">Elérési út</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> - <string name="playback_debug_info">Lejátszás hibakeresési adatainak mutatása</string> - <string name="playback_speed">Lejátszási sebesség</string> - <string name="playback">Lejátszás</string> - <string name="playlist">Lejátszási lista</string> - <string name="playlists">Lejátszási listák</string> - <string name="preview">Előnézet</string> - <string name="profile_specific_settings">Felhasználó beállításai</string> - <string name="queue">Lejátszási sor</string> - <string name="recently_added_in">Nemrég hozzáadva: %1$s</string> - <string name="recently_added">Nemrég hozzáadott</string> - <string name="recently_recorded">Nemrég rögzített</string> - <string name="recently_released">Nemrég megjelent</string> - <string name="recommended">Ajánlott</string> - <string name="record_program">Műsor felvétele</string> - <string name="record_series">Sorozat felvétele</string> - <string name="remove_favorite">Törlés a kedvencekből</string> - <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 é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> - <string name="select_user">Felhasználó kiválasztása</string> - <string name="show_debug_info">Hibakeresési infó megjelenítése</string> - <string name="show_next_up_when">Következő mutatása</string> - <string name="show">Megmutat</string> - <string name="shuffle">Véletlenszerű</string> - <string name="skip">Átugrás</string> - <string name="sort_by_date_added">Hozzáadás dátuma</string> - <string name="sort_by_date_episode_added">Epizód hozzáadásának dátuma</string> - <string name="sort_by_date_played">Lejátszás dátuma</string> - <string name="sort_by_date_released">Megjelenés dátuma</string> - <string name="sort_by_name">Név</string> - <string name="sort_by_random">Véletlenszerűen</string> - <string name="studios">Stúdiók</string> - <string name="submit">Beküld</string> - <string name="subtitle_download_too_long">A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítani a lejátszást</string> - <string name="subtitle">Felirat</string> - <string name="subtitles">Feliratok</string> - <string name="suggestions">Ajánlások</string> - <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> - <plurals name="trailers"> - <item quantity="one">Előzetes</item> - <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_title">Sorozatok</string> - <string name="ui_interface">Kezelőfelület</string> - <string name="unknown">Ismeretlen</string> - <string name="updates">Frissítések</string> - <string name="version">Verzió</string> - <string name="video_scale">Kép méretezése</string> - <string name="video">Videó</string> - <string name="videos">Videók</string> - <string name="watch_live">Nézze élőben</string> - <plurals name="years_old"> - <item quantity="one">%1$d éves</item> - <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> - <string name="create_playlist">Új lejátszási lista létrehozása</string> - <string name="add_to_playlist">Hozzáadás lejátszási listához</string> - <string name="one_click_pause">Szüneteltetés egy klikkel</string> - <string name="one_click_pause_summary_on">Nyomja le a D-Pad közepét megállításhoz/lejátszáshoz</string> - <string name="italic_font">Dőlt betűk</string> - <string name="font">Betűk stílusa</string> - <string name="background">Háttér</string> - <string name="success">Sikeres</string> - <string name="official_rating">Korhatár-besorolás</string> - <string name="runtime_sort">Hossz</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">A kulisszák mögött</item> - <item quantity="other">A kulisszák mögött</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Főcímdal</item> - <item quantity="other">Főcímdalok</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Téma videó</item> - <item quantity="other">Téma videók</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klip</item> - <item quantity="other">Klipek</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Törölt jelenet</item> - <item quantity="other">Törölt jelenetek</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Interjú</item> - <item quantity="other">Interjúk</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Jelenet</item> - <item quantity="other">Jelenetek</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Minta</item> - <item quantity="other">Minták</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Rövidfilm</item> - <item quantity="other">Rövidfilmek</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Rövid</item> - <item quantity="other">Rövidek</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s letöltés</item> - <item quantity="other">%s letöltések</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d óra</item> - <item quantity="other">%d órák</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d elem</item> - <item quantity="other">%d elemek</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d másodperc</item> - <item quantity="other">%d másodpercek</item> - </plurals> - <string name="ends_at">%1$s-kor ér véget</string> - <string name="no_servers_found">Nem található szerver</string> - <string name="recap">Összefoglaló</string> - <string name="media_information">Médiainformáció</string> - <string name="ac3_supported">A készülék támogatja az AC3/Dolby Digital formátumot</string> - <string name="advanced_settings">Haladó beállítások</string> - <string name="advanced_ui">Haladó kezelőfelület-beállítások</string> - <string name="app_theme">Alkalmazás témája</string> - <string name="auto_check_for_updates">Frissítések automatikus keresése</string> - <string name="auto_play_next_delay">Késleltetés a következő elem lejátszása előtt</string> - <string name="auto_play_next">Következő elem automatikus lejátszása</string> - <string name="check_for_updates">Frissítések keresése</string> - <string name="combine_continue_next_summary">Csak sorozatok esetén</string> - <string name="direct_play_ass">ASS feliratok közvetlen lejátszása</string> - <string name="direct_play_pgs">PGS feliratok közvetlen lejátszása</string> - <string name="downmix_stereo">Mindig konvertálja sztereóvá</string> - <string name="ffmpeg_extension_pref">FFmpeg dekóder modul használata</string> - <string name="global_content_scale">Tartalom alapértelmezett méretezése</string> - <string name="install_update">Frissítés telepítése</string> - <string name="installed_version">Telepített verzió</string> - <string name="max_homepage_items">A kezdőlap sorainak maximális elemszáma</string> - <string name="nav_drawer_pins_summary">Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve</string> - <string name="nav_drawer_pins">Navigációs menü elemeinek testreszabása</string> - <string name="nav_drawer_switch_on_focus_summary_off">Kattintson az oldalak közti váltáshoz</string> - <string name="pass_out_protection">Automatikus leállítás inaktivitás esetén</string> - <string name="play_theme_music">Főcímdal lejátszása</string> - <string name="remember_selected_tab">Kiválasztott lapok megjegyzése</string> - <string name="rewatch_next_up">Újranézés engedélyezése a Következő menüben</string> - <string name="seek_bar_steps">Előre/hátra ugrás intervallum</string> - <string name="send_app_logs_summary">Hibakereséshez hasznos</string> - <string name="send_app_logs">Naplófájlok küldése a jelenlegi szerverre</string> - <string name="send_crash_reports_summary">Megpróbálja elküldeni az legutóbb csatlakozott szerverre</string> - <string name="send_crash_reports">Összeomlási napló küldése</string> - <string name="settings">Beállítások</string> - <string name="skip_back_on_resume_preference">Visszaugrás a lejátszás folytatásakor</string> - <string name="skip_back_preference">Visszaugrás</string> - <string name="skip_commercials_behavior">Reklám-átugrás viselkedés</string> - <string name="skip_forward_preference">Előreugrás</string> - <string name="skip_intro_behavior">Intró-átugrás viselkedés</string> - <string name="skip_outro_behavior">Stáblista-átugrás viselkedés</string> - <string name="skip_previews_behavior">Előzetes-átugrás viselkedés</string> - <string name="skip_recap_behavior">Összefoglaló-átugrás viselkedés</string> - <string name="update_available">Frissítés érhető el</string> - <string name="update_url_summary">Az alkalmazás frissítéseinek ellenőrzésére használt URL</string> - <string name="update_url">Frissítés URL</string> - <string name="font_size">Betűméret</string> - <string name="font_color">Betűszín</string> - <string name="font_opacity">Betűk áttetszősége</string> - <string name="edge_style">Szegély stílusa</string> - <string name="edge_color">Szegély színe</string> - <string name="background_opacity">Háttér áttetszősége</string> - <string name="background_style">Háttér típusa</string> - <string name="subtitle_style">Felirat stílusa</string> - <string name="background_color">Háttér színe</string> - <string name="reset">Visszaállítás</string> - <string name="bold_font">Félkövér betűk</string> - <string name="mpv_hardware_decoding">MPV: hardveres dekódolás használata</string> - <string name="disable_if_crash">Tiltsa le, ha összeomlást tapasztal</string> - <string name="mpv_options">MPV beállítások</string> - <string name="exoplayer_options">ExoPlayer beállítások</string> - <string name="skip_segment_unknown">Szegmens átugrása</string> - <string name="skip_segment_commercial">Reklám átugrása</string> - <string name="skip_segment_preview">Előzetes átugrása</string> - <string name="skip_segment_recap">Összefoglaló átugrása</string> - <string name="skip_segment_outro">Stáblista átugrása</string> - <string name="skip_segment_intro">Intró átugrása</string> - <string name="played">Megnézett</string> - <string name="filter">Szűrő</string> - <string name="year">Év</string> - <string name="decade">Évtized</string> - <string name="remove">Eltávolítás</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: gpu-next használata</string> - <string name="mpv_conf">mpv.conf szerkesztése</string> - <string name="discard_change">Elveti a változtatásokat?</string> - <string name="subtitle_margin">Függőleges eltolás</string> - <string name="verbose_logging">Részletes naplózás</string> - <string name="enter_pin">Adja meg a PIN-kódot</string> - <string name="sign_in_auto">Automatikus bejelentkezés</string> - <string name="use_server_credentials">Bejelentkezés szerveren keresztül</string> - <string name="press_enter_to_confirm">Nyomja meg a középső gombot a megerősítéshez</string> - <string name="require_pin_code">Kérjen PIN-kódot a profillal történő bejelentkezéshez</string> - <string name="confirm_pin">PIN-kód megerősítése</string> - <string name="incorrect">Helytelen</string> - <string name="pin_too_short">A PIN-kódnak legalább 4 karakterből kell állnia</string> - <string name="will_remove_pin">PIN-kód eltávolítása</string> - <string name="view_options">Nézetbeállítások</string> - <string name="columns">Oszlopok</string> - <string name="spacing">Térköz</string> - <string name="aspect_ratio">Képarány</string> - <string name="show_details">Részletek megjelenítése</string> - <string name="image_type">Képtípus</string> - <string name="guest_stars">Vendégszereplők</string> - <string name="edge_size">Szegély mérete</string> - <string name="refresh_rate_switching">Tartalomhoz igazodó képfrissítési gyakoriság</string> - <string name="automatic">Automatikus</string> - <string name="username_or_password">Felhasználónév/jelszó használata</string> - <string name="login">Bejelentkezés</string> - <string name="general">Általános</string> - <string name="container">Konténer</string> - <string name="title">Cím</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Szint</string> - <string name="resolution">Felbontás</string> - <string name="anamorphic">Anamorfikus</string> - <string name="interlaced">Váltottsoros</string> - <string name="framerate">Képfrissítés</string> - <string name="bit_depth">Színmélység</string> - <string name="video_range">Feketeszint tartomány</string> - <string name="video_range_type">Feketeszint tartomány típusa</string> - <string name="color_space">Színtér</string> - <string name="color_transfer">Színátviteli jellemző</string> - <string name="color_primaries">Alapszínek</string> - <string name="pixel_format">Pixelformátum</string> - <string name="ref_frames">Referencia-képkockák</string> - <string name="nal">NAL</string> - <string name="language">Nyelv</string> - <string name="layout">Elrendezés</string> - <string name="channels">Csatornák</string> - <string name="sample_rate">Mintavételezés</string> - <string name="avc">AVC</string> - <string name="yes">Igen</string> - <string name="no">Nem</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">Címek mutatása</string> - <string name="live_tv_repeat">Ismétlés</string> - <string name="favorite_channels_at_beginning">A kedvenc csatornákat mutassa először</string> - <string name="sort_channels_recently_watched">Legutóbb nézett csatornák előre sorolása</string> - <string name="color_code_programs">Műsorok kategória szerinti színezése</string> - <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[A „Folytatás” és a „Következő epizód” ö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[Szereplők és stáb]]></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> - <string name="play_trailer">Előzetes lejátszása</string> - <string name="no_trailers">Nincsenek előzetesek</string> - <string name="local">Helyi</string> - <string name="dts_x">DTS:X</string> - <string name="dts_hd">DTS-HD</string> - <string name="truehd">TrueHD</string> - <string name="dolby_digital">DD</string> - <string name="dolby_digital_plus">DD+</string> - <string name="dts">DTS</string> - <string name="only_forced_subtitles">Csak kiegészítő felirat</string> - <string name="clear_track_choices">Sávválasztás törlése</string> - <string name="duration_hours_minutes_seconds">%1$dó %2$dp %3$dmp</string> - <string name="duration_hours_minutes">%1$dó %2$dp</string> - <string name="duration_hours_seconds">%1$dó %2$dmp</string> - <string name="duration_minutes_seconds">%1$dp %2$dmp</string> - <string name="duration_hours">%1$dó</string> - <string name="duration_minutes">%1$dp</string> - <string name="duration_seconds">%1$dmp</string> - <string name="profile_specific_settings_from_server">Felhasználói profilbeállítások a szerverről</string> - <string name="voice_search">Hangalapú keresés</string> - <string name="voice_starting">Indítás</string> - <string name="voice_search_prompt">Keresés hanggal</string> - <string name="processing">Feldolgozás</string> - <string name="press_back_to_cancel">A megszakításhoz nyomd meg a Vissza gombot</string> - <string name="voice_error_audio">Hangfelvételi hiba</string> - <string name="voice_error_client">Hangfelismerési hiba</string> - <string name="voice_error_permissions">Mikrofonengedély szükséges</string> - <string name="voice_error_network">Hálózati hiba</string> - <string name="voice_error_network_timeout">Hálózati időtúllépés</string> - <string name="voice_error_no_match">Nem sikerült felismerni a beszédet</string> - <string name="voice_error_busy">A hangfelismerés foglalt</string> - <string name="voice_error_server">Szerverhiba</string> - <string name="voice_error_speech_timeout">Nem észlelhető beszéd</string> - <string name="voice_error_unknown">Ismeretlen hiba</string> - <string name="voice_error_start_failed">Nem sikerült elindítani a hangfelismerést</string> - <string name="voice_error_timeout">A hangfelismerés időtúllépés miatt megszakadt</string> - <string name="retry">Újrapróbálás</string> - <string name="trailers_title">Előzetesek</string> - <string name="season_number">%1$d. évad</string> - <string name="other_extras_title">Egyéb</string> - <string name="behind_the_scenes_title">Werkfilmek</string> - <string name="theme_songs_title">Főcímdalok</string> - <string name="theme_videos_title">Főcímvideók</string> - <string name="clips_title">Klipek</string> - <string name="deleted_scenes_title">Törölt jelenetek</string> - <string name="interviews_title">Interjúk</string> - <string name="scenes_title">Jelenetek</string> - <string name="samples_title">Minták</string> - <string name="featurettes_title">Rövid werkfilmek</string> - <string name="shorts_title">Rövidfilmek</string> - <string name="favorite_items_title">Kedvenc %s</string> - <plurals name="days"> - <item quantity="one">%s nap</item> - <item quantity="other">%s napok</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">%s perc</item> - <item quantity="other">%s percek</item> - </plurals> - <plurals name="movies"> - <item quantity="one">Film</item> - <item quantity="other">Filmek</item> - </plurals> - <plurals name="tv_shows"> - <item quantity="one">Sorozat</item> - <item quantity="other">Sorozatok</item> - </plurals> - <plurals name="people"> - <item quantity="one">Személy</item> - <item quantity="other">Személyek</item> - </plurals> - <string name="require_login">Bejelentkezés szükséges a profil használatához</string> - <string name="pin_digits_hint">[0–9]</string> - <string name="disable_audio_offload">Hardveres hangfeldolgozás kikapcsolása</string> - <string name="disable_audio_offload_summary">A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat</string> - <string name="combined_search_results">Összesített keresési találatok</string> - <string name="combined_search_results_on">Az összes találat megjelenítése egyetlen listában</string> - <string name="combined_search_results_off">Találatok megjelenítése kategóriánként</string> - <string name="show_voice_search_button">Hangalapú keresés gomb megjelenítése</string> - <string name="visible_ui">Látható</string> - <string name="hidden_ui">Rejtett</string> - <string name="results">Találatok</string> - <string name="force_dovi_profile_7">Dolby Vision 7-es profil közvetlen lejátszása</string> - <string name="force_dovi_profile_7_summary">Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését</string> - <string name="prefer_dovi_over_hdr10_plus">A Dolby Vision előnyben részesítése a HDR10+-szal szemben</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket</string> - <string name="discover">Felfedezés</string> - <string name="request">Kérés</string> - <string name="pending">Függőben</string> - <string name="seerr_integration">Seerr-integráció</string> - <string name="remove_seerr_server">Seerr-szerver eltávolítása</string> - <string name="seerr_server_added">Seerr-szerver hozzáadva</string> - <string name="quick_connect">Gyors csatlakozás</string> - <string name="quick_connect_summary">Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba</string> - <string name="quick_connect_code">Add meg a gyors csatlakozási kódot</string> - <string name="quick_connect_code_error">A kódnak 6 számjegyből kell állnia</string> - <string name="quick_connect_success">Az eszköz engedélyezése sikeres</string> - <string name="password">Jelszó</string> - <string name="username">Felhasználónév</string> - <string name="url">URL</string> - <string name="trending">Népszerű most</string> - <string name="upcoming_movies">Hamarosan megjelenő filmek</string> - <string name="upcoming_tv">Hamarosan induló sorozatok</string> - <string name="request_4k">Kérés 4K-ban</string> - <string name="software_decoding_av1">AV1 szoftveres dekódolása</string> - <string name="upgrade_mpv_toast">Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod.</string> - <string name="hdr_subtitle_style">HDR-feliratok stílusa</string> - <string name="image_subtitle_opacity">Képalapú feliratok átlátszatlansága</string> - <string name="brightness">Fényerő</string> - <string name="contrast">Kontraszt</string> - <string name="saturation">Telítettség</string> - <string name="hue">Színárnyalat</string> - <string name="save_for_album">Mentés albumhoz</string> - <string name="play_slideshow">Diavetítés indítása</string> - <string name="stop_slideshow">Diavetítés leállítása</string> - <string name="slideshow_at_beginning">Az elején</string> - <string name="no_more_images">Nincs több fénykép</string> - <string name="rotate_left">Forgatás balra</string> - <string name="rotate_right">Forgatás jobbra</string> - <string name="zoom_in">Nagyítás</string> - <string name="zoom_out">Kicsinyítés</string> - <string name="slideshow_duration">Diavetítés időtartama</string> - <string name="play_videos_during_slideshow">Videók lejátszása diavetítés közben</string> - <string name="send_media_info_log_to_server">Médiaadat-napló küldése a szervernek</string> - <string name="no_limit">Korlátlan</string> - <string name="max_days_next_up">A „Következő” listában megjelenő napok maximális száma</string> - <string name="albums">Albumok</string> - <string name="artists">Előadók</string> - <string name="songs">Dalok</string> - <string name="go_to_artist">Ugrás az előadóhoz</string> - <string name="now_playing">Most játszott tartalom</string> - <string name="instant_mix">Azonnali mix</string> - <string name="go_to_album">Ugrás az albumhoz</string> - <string name="add_to_queue">Hozzáadás a lejátszási sorhoz</string> - <string name="add_row">Sor hozzáadása</string> - <string name="genres_in">Műfajok %1$s</string> - <string name="recently_released_in">Nemrég megjelent %1$s</string> - <string name="height">Magasság</string> - <string name="apply_all_rows">Alkalmazás az összes sorra</string> - <string name="customize_home">Kezdőlap testreszabása</string> - <string name="home_rows">Kezdőlap sorai</string> - <string name="load_from_server">Betöltés a szerver felhasználói profiljából</string> - <string name="save_to_server">Mentés a szerver felhasználói profiljába</string> - <string name="load_from_web_client">Betöltés a webes kliensből</string> - <string name="use_series">Sorozat képének használata</string> - <string name="add_row_for">Sor hozzáadása ehhez: %1$s</string> - <string name="overwrite_server_settings">Felülírod a szerveren lévő beállításokat?</string> - <string name="overwrite_local_settings">Felülírod a helyi beállításokat?</string> - <string name="for_episodes">Epizódokhoz</string> - <string name="suggestions_for">Javaslatok ehhez: %1$s</string> - <string name="increase_all_cards_size">Az összes kártya méretének növelése</string> - <string name="decrease_all_cards_size">Az összes kártya méretének csökkentése</string> - <string name="use_thumb_images">Bélyegképek használata</string> - <string name="settings_saved">Beállítások mentve</string> - <string name="display_presets">Kijelző-beállítások</string> - <string name="display_presets_description">Beépített előbeállítások az összes sor gyors formázásához</string> - <string name="customize_home_summary">Sorok és képek kiválasztása a kezdőlapon</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Nem sztereó hang átkódolása AC3/Dolby Digital formátumba]]></string> - <string name="prefer_ac3_for_surround">Térhatású hang használata AC3 formátumban</string> - <string name="start_after">Kezdés ennyi idő múlva</string> - <string name="animate">Animálás</string> - <string name="max_age_rating">Maximális korhatár</string> - <string name="no_max">Nincs felső korlát</string> - <string name="for_all_ages">Minden korosztály számára</string> - <string name="up_to_age">%s éves korig</string> - <string name="screensaver">Képernyővédő</string> - <string name="screensaver_settings">Képernyővédő beállítások</string> - <string name="start_screensaver">Képernyővédő elindítása</string> - <string name="duration">Időtartam</string> - <string name="photos">Fotók</string> - <string name="include_types">Típusok felvétele</string> - <string name="include_types_summary">Mely elemtípusokhoz jelenjenek meg képek</string> - <string name="content_scale_fit">Illesztés</string> - <string name="content_scale_crop">Kivágás</string> - <string name="content_scale_fill">Kitöltés</string> - <string name="content_scale_fill_width">Szélesség kitöltése</string> - <string name="content_scale_fill_height">Magasság kitöltése</string> - <string name="display_preset_default">Wholphin alapértelmezett beállítása</string> - <string name="display_preset_compact">Wholphin kompakt elrendezés</string> - <string name="display_preset_series_thumb">A sorozat miniatűrképei</string> - <string name="display_preset_episode_thumbnails">Az epizódok miniatűrképei</string> - <string name="volume_lowest">Legalacsonyabb</string> - <string name="volume_low">Alacsony</string> - <string name="volume_medium">Közepes</string> - <string name="volume_high">Magas</string> - <string name="volume_full">Teljes</string> - <string name="purple">Lila</string> - <string name="orange">Narancs</string> - <string name="bold_blue">Élénk kék</string> - <string name="black">Fekete</string> - <string name="brown">Barna</string> - <string name="skip_ignore">Kihagyás</string> - <string name="skip_automatically">Automatikus átugrás</string> - <string name="skip_ask">Rákérdezés a kihagyásra</string> - <string name="ffmpeg_fallback">Az FFmpeg használata csak akkor, ha nincs beépített dekóder</string> - <string name="ffmpeg_prefer">Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben</string> - <string name="ffmpeg_never">FFmpeg-dekóderek használatának tiltása</string> - <string name="next_up_playback_end">A lejátszás végén</string> - <string name="hdr_formats">HDR-formátumok</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision 5-ös profil</string> - <string name="dovi_profile_7">Dolby Vision 7-es profil</string> - <string name="dovi_profile_8">Dolby Vision 8-as profil</string> - <string name="force_enable">Kényszerített engedélyezés</string> - <string name="force_disable">Kényszerített letiltás</string> - <string name="red">Piros</string> - <string name="green">Zöld</string> - <string name="blue">Kék</string> - <string name="blur">Elmosódás</string> - <string name="next_up_outro">A stáblista és a zárójelenetek alatt</string> - <string name="next_up_never">Soha</string> - <string name="white">Fehér</string> - <string name="light_gray">Világosszürke</string> - <string name="dark_gray">Sötétszürke</string> - <string name="yellow">Sárga</string> - <string name="cyan">Cián</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Körvonal</string> - <string name="subtitle_edge_shadow">Árnyék</string> - <string name="background_style_wrap">Sortörés</string> - <string name="prefer_mpv">Az MPV előnyben részesítése</string> - <string name="external_player">Külső lejátszó</string> - <string name="player_backend_options_subtitles_prefer_mpv">Az ExoPlayer használata HDR-lejátszáshoz</string> - <string name="aspect_ratios_poster">Poszter (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">Négyzet (1:1)</string> - <string name="image_type_primary">Elsődleges</string> - <string name="image_type_thumb">Bélyegkép</string> - <string name="view_options_type_grid">Rács</string> - <string name="view_options_type_list">Lista</string> - <string name="view_options_type_list_compact">Összefoglaló lista</string> - <string name="backdrop_style_dynamic">Dinamikus színű kép</string> - <string name="backdrop_style_image">Csak kép</string> - <string name="enter_url_api_key"><![CDATA[Add meg az URL-t és az API-kulcsot]]></string> - <string name="api_key">API-kulcs</string> - <string name="seerr_login">Bejelentkezés a Seerr-szerverre</string> - <string name="seerr_jellyfin_user">Jellyfin-felhasználó</string> - <string name="seerr_local_user">Helyi felhasználó</string> - <string name="search_and_download_subtitles"><![CDATA[Feliratok keresése és letöltése]]></string> - <string name="no_subtitles_found">Nem található távoli felirat</string> - <string name="series_continueing">Jelenlegi</string> - <string name="in_app_screensaver">Az alkalmazáson belüli képernyővédő használata</string> - <string name="delete_item">Biztosan törölni szeretnéd ezt az elemet?</string> - <string name="show_media_management">Médiakezelési lehetőségek megjelenítése</string> - <string name="search_for">Keresés %s</string> - <string name="actor">Színész</string> - <string name="composer">Zeneszerző</string> - <string name="writer">Író</string> - <string name="guest_star">Vendégszereplő</string> - <string name="producer">Producer</string> - <string name="conductor">Karmester</string> - <string name="lyricist">Dalszövegíró</string> - <string name="arranger">Rendezés</string> - <string name="engineer">Mérnök</string> - <string name="mixer">Hangmérnök</string> - <string name="creator">Alkotó</string> - <string name="artist">Művész</string> - <string name="album_artist">Album előadója</string> - <string name="album">Album</string> - <string name="popular_songs">Népszerű dalok</string> - <string name="play_next">Lejátszás következőként</string> - <string name="music_videos">Videóklipek</string> - <string name="lyrics">Dalszövegek</string> - <string name="hide_lyrics">Dalszöveg elrejtése</string> - <string name="show_lyrics">Dalszöveg megjelenítése</string> - <string name="remove_from_queue">Eltávolítás a lejátszási sorból</string> - <string name="show_album_cover">Albumborító megjelenítése</string> - <string name="show_visualizer">Vizuális effektusok megjelenítése</string> - <string name="show_backdrop">Háttér megjelenítése</string> - <string name="play_as_type">Lejátszás másként?</string> - <string name="visualizer_rationale">A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést.</string> - <string name="continue_string">Folytatás</string> - <string name="select_all">Összes kijelölése</string> - <string name="separate_types">Típusok különválasztása</string> - <string name="prefer_logos">A címek logóinak előnyben részesítése</string> - <string name="updated_toast">A Wholphin frissült a %s verzióra</string> - <string name="remove_continue_watching">Eltávolítás a „Folytatás” listából</string> - <string name="remove_next_up">Sorozat eltávolítása a „Következő epizód” listából</string> - <string name="view_removed_next_up">A „Következő epizód” listából eltávolított sorozatok megtekintése</string> - <string name="view_more">További információk</string> -</resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 623ef596..065bc445 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -10,38 +10,38 @@ <string name="bitrate">Bitrate</string> <string name="born">Lahir</string> <string name="cancel_recording">Batalkan Perekaman</string> - <string name="cancel_series_recording">Batalkan Perekaman Seri</string> + <string name="cancel_series_recording">Batalkan Perekaman Serial</string> <string name="cancel">Batal</string> - <string name="chapters">Chapter</string> + <string name="chapters">Segmen</string> <string name="choose_stream">Pilih %1$s</string> <string name="clear_image_cache">Hapus cache gambar</string> <string name="collection">Koleksi</string> <string name="collections">Koleksi</string> <string name="commercial">Komersial</string> - <string name="community_rating">Rating Komunitas</string> + <string name="community_rating">Peringkat Komunitas</string> <string name="compose_error_message_title">Terjadi kesalahan! Tekan tombol untuk mengirim log ke server kamu.</string> <string name="confirm">Konfirmasi</string> <string name="continue_watching">Lanjutkan menonton</string> - <string name="critic_rating">Rating Kritis</string> - <string name="decimal_seconds">%.2f detik</string> - <string name="default_track">Bawaan</string> + <string name="critic_rating">Penilaian Kritis</string> + <string name="decimal_seconds">%.1f detik</string> + <string name="default_track">Setelan Awal</string> <string name="delete">Hapus</string> <string name="died">Meninggal</string> <string name="directed_by">Disutradarai oleh %1$s</string> <string name="director">Sutradara</string> <string name="file_size">Ukuran</string> <string name="downloading">Mengunduh…</string> - <string name="enter_server_url">Masukkan IP Server atau URL</string> + <string name="enter_server_url">Masukkan IP Server atau URL termasuk port</string> <string name="episodes">Episode</string> <string name="error_loading_collection">Gagal memuat koleksi %1$s</string> <string name="discovered_servers">Server Ditemukan</string> - <string name="download_and_update"><![CDATA[Unduh & Perbarui]]></string> + <string name="download_and_update"><![CDATA[Download & Update]]></string> <string name="external_track">Eksternal</string> <string name="favorites">Favorit</string> - <string name="disabled">Nonaktif</string> - <string name="enabled">Aktif</string> + <string name="disabled">Dinonaktifkan</string> + <string name="enabled">Diktifkan</string> <string name="genres">Genre</string> - <string name="go_to_series">Buka seri</string> + <string name="go_to_series">Masuk ke serial</string> <string name="go_to">Masuk ke</string> <string name="forced_track">Paksa</string> <string name="hide_controller_timeout">Sembunyikan kontrol pemutaran</string> @@ -53,7 +53,7 @@ <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> <string name="library">Pustaka</string> <string name="license_info">Informasi lisensi</string> - <string name="live_tv">Live TV</string> + <string name="live_tv">TV Live</string> <string name="loading">Memuat…</string> <string name="mark_entire_series_as_played">Tandai semua episode sudah ditonton?</string> <string name="mark_entire_series_as_unplayed">Tandai semua episode belum ditonton?</string> @@ -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_title">Film</string> + <string name="movies">Film</string> <string name="name">Nama</string> <string name="next_up">Selanjutnya</string> <string name="no_data">Tidak ada data</string> @@ -72,15 +72,15 @@ <string name="none">Tidak ada</string> <string name="outro">Outro</string> <string name="path">Berkas</string> - <string name="people_title">Orang</string> + <string name="people">Orang</string> <string name="play_count">Total Pemutaran</string> <string name="play_from_here">Putar dari sini</string> <string name="play">Putar</string> - <string name="playback_debug_info">Tampilkan informasi statistik pemutaran</string> + <string name="playback_debug_info">Tampilkan info debug pemutaran</string> <string name="playback_speed">Kecepatan Pemutaran</string> <string name="playback">Pemutaran</string> - <string name="playlist">Playlist</string> - <string name="playlists">Playlist</string> + <string name="playlist">Daftar Putar</string> + <string name="playlists">Daftar Putar</string> <string name="preview">Pratinjau</string> <string name="profile_specific_settings">Pengaturan Profil</string> <string name="queue">Antrean</string> @@ -91,12 +91,12 @@ <string name="recently_released">Baru Dirilis</string> <string name="recommended">Rekomendasi</string> <string name="record_program">Rekam Program</string> - <string name="record_series">Rekam Seri</string> + <string name="record_series">Rekam Serial</string> <string name="remove_favorite">Hapus dari Favorit</string> <string name="restart">Mulai ulang</string> <string name="resume">Lanjutkan</string> <string name="save">Simpan</string> - <string name="search_and_download"><![CDATA[Pencarian & Unduhan]]></string> + <string name="search_and_download"><![CDATA[Search & Download]]></string> <string name="search">Pencarian</string> <string name="searching">Mencari…</string> <string name="select_server">Pilih Server</string> @@ -116,19 +116,20 @@ <string name="submit">kirim</string> <string name="subtitle_download_too_long">Unduhan terlalu lama, coba mulai ulang pemutaran</string> <string name="subtitle">Subtitel</string> - <string name="subtitles">Subtitel</string> + <string name="subtitles">Subtitle</string> <string name="suggestions">Rekomendasi</string> <string name="switch_servers">Ganti Server</string> <string name="switch_user">Ganti</string> <string name="top_unwatched">Terbaik Belum Ditonton</string> + <string name="trailer">Cuplikan</string> <plurals name="trailers"> - <item quantity="other">Trailer</item> + <item quantity="other">Cuplikan</item> </plurals> <string name="tv_dvr_schedule">Jadwal DVR</string> <string name="tv_guide">Panduan</string> - <string name="tv_season">Season</string> - <string name="tv_seasons">Season</string> - <string name="tv_shows_title">Acara TV</string> + <string name="tv_season">Musim</string> + <string name="tv_seasons">Musim</string> + <string name="tv_shows">Acara TV</string> <string name="ui_interface">Antarmuka</string> <string name="unknown">Tidak Diketahui</string> <string name="updates">Pembaruan</string> @@ -137,40 +138,38 @@ <string name="video">Video</string> <string name="videos">Video</string> <string name="watch_live">Tonton langsung</string> - <plurals name="years_old"> - <item quantity="other">%1$d tahun</item> - </plurals> + <string name="years_old">%1$d tahun</string> <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> - <string name="create_playlist">Buat playlist baru</string> - <string name="add_to_playlist">Tambahkan ke playlist</string> + <string name="create_playlist">Buat Daftar Putar Baru</string> + <string name="add_to_playlist">Tambahkan ke Daftar Putar</string> <string name="one_click_pause">Jeda sekali klik</string> <string name="one_click_pause_summary_on">Tekan pusat D-Pad untuk jeda/putar</string> <string name="italic_font">Teks miring</string> <string name="font">Font</string> <string name="background">Latar Belakang</string> <string name="success">Berhasil</string> - <string name="official_rating">Rating Orang Tua</string> + <string name="official_rating">Peringkat Orang Tua</string> <string name="runtime_sort">Durasi</string> - <string name="extras_title">Ekstra</string> + <string name="extras">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> @@ -185,7 +184,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> @@ -204,15 +203,15 @@ <string name="auto_play_next_delay">Jeda sebelum putar selanjutnya</string> <string name="auto_play_next">Putar otomatis selanjutnya</string> <string name="check_for_updates">Periksa Pembaruan</string> - <string name="combine_continue_next_summary">Hanya berlaku untuk Seri TV</string> - <string name="combine_continue_next"><![CDATA[Gabungkan Lanjut Menonton & Berikutnya]]></string> - <string name="direct_play_ass">Gunakan libass untuk subtitel ASS</string> - <string name="direct_play_pgs">Putar langsung subtitel PGS</string> + <string name="combine_continue_next_summary">Hanya berlaku untuk Serial TV</string> + <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> + <string name="direct_play_ass">Putar langsung subtitle ASS</string> + <string name="direct_play_pgs">Putar langsung subtitle PGS</string> <string name="downmix_stereo">Selalu downmix ke stereo</string> <string name="ffmpeg_extension_pref">Gunakan dekoder FFmpeg</string> <string name="global_content_scale">Skala konten bawaan</string> - <string name="install_update">Pasang pembaruan</string> - <string name="installed_version">Versi terpasang</string> + <string name="install_update">Pasang Pembaruan</string> + <string name="installed_version">Versi Terpasang</string> <string name="max_homepage_items">Maks item pada baris halaman utama</string> <string name="nav_drawer_pins_summary">Pilih item bawaan yang akan ditampilkan, lainnya akan disembunyikan</string> <string name="nav_drawer_pins">Kustomisasi Item Navigasi</string> @@ -243,8 +242,8 @@ <string name="font_size">Ukuran font</string> <string name="font_color">Warna font</string> <string name="font_opacity">Transparansi font</string> - <string name="edge_style">Jenis pinggiran</string> - <string name="edge_color">Warna pinggiran</string> + <string name="edge_style">Jenis garis tepi</string> + <string name="edge_color">Warna garis tepi</string> <string name="background_opacity">Transparansi latar belakang</string> <string name="background_style">Gaya latar belakang</string> <string name="subtitle_style">Gaya subtitel</string> @@ -259,455 +258,4 @@ <plurals name="downloads"> <item quantity="other">%s unduhan</item> </plurals> - <string name="skip_segment_unknown">Lewati Segmen</string> - <string name="skip_segment_commercial">Lewati Iklan</string> - <string name="skip_segment_preview">Lewati Pratinjau</string> - <string name="skip_segment_recap">Lewati Rekap</string> - <string name="skip_segment_outro">Lewati Outro</string> - <string name="skip_segment_intro">Lewati Intro</string> - <string name="played">Sudah Diputar</string> - <string name="filter">Filter</string> - <string name="year">Tahun</string> - <string name="decade">Dekade</string> - <string name="remove">Hapus</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="media_information">Informasi Media</string> - <string name="mpv_use_gpu_next">MPV: Gunakan gpu-next</string> - <string name="mpv_conf">Edit mpv.conf</string> - <string name="discard_change">Batalkan perubahan?</string> - <string name="enter_pin">Masukkan PIN</string> - <string name="sign_in_auto">Masuk secara otomatis</string> - <string name="use_server_credentials">Masuk via server</string> - <string name="press_enter_to_confirm">Tekan tengah untuk konfirmasi</string> - <string name="confirm_pin">Konfirmasi PIN</string> - <string name="incorrect">Salah</string> - <string name="pin_too_short">PIN harus 4 digit atau lebih</string> - <string name="image_cache_size">Ukuran cache disk gambar (MB)</string> - <string name="columns">Kolom</string> - <string name="aspect_ratio">Rasio Aspek</string> - <string name="show_details">Tampilkan detail</string> - <string name="image_type">Jenis gambar</string> - <string name="cast_and_crew"><![CDATA[Pemeran & Kru]]></string> - <string name="guest_stars">Pemeran Tamu</string> - <string name="anamorphic">Anamorfik</string> - <string name="interlaced">Interlace</string> - <string name="framerate">Laju frame</string> - <string name="bit_depth">Kedalaman bit</string> - <string name="video_range">Rentang video</string> - <string name="video_range_type">Jenis rentang video</string> - <string name="color_space">Ruang warna</string> - <string name="pixel_format">Format piksel</string> - <string name="nal">NAL</string> - <string name="language">Bahasa</string> - <string name="avc">AVC</string> - <string name="yes">Ya</string> - <string name="no">Tidak</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">Tampilkan judul</string> - <string name="live_tv_repeat">Ulangi</string> - <string name="favorite_channels_at_beginning">Tampilkan saluran favorit dahulu</string> - <string name="sort_channels_recently_watched">Urutkan saluran berdasarkan yang baru ditonton</string> - <string name="color_code_programs">Program kode warna</string> - <string name="verbose_logging">Pencatatan rinci</string> - <string name="require_pin_code">Membutuhkan PIN untuk profil</string> - <string name="will_remove_pin">PIN akan dihapus</string> - <string name="view_options">Opsi tampilan</string> - <string name="edge_size">Ukuran pinggiran</string> - <string name="automatic">Otomatis</string> - <string name="username_or_password">Gunakan nama pengguna/kata sandi</string> - <string name="subtitle_margin">Batas tepi</string> - <string name="login">Masuk</string> - <string name="general">Umum</string> - <string name="container">Kontainer</string> - <string name="title">Judul</string> - <string name="codec">Codec</string> - <string name="profile">Profil</string> - <string name="level">Level</string> - <string name="resolution">Resolusi</string> - <string name="sample_rate">Sampel rate</string> - <string name="spacing">Jarak</string> - <string name="ends_at">Berakhir pada %1$s</string> - <string name="enter_server_address">Masukkan alamat server</string> - <string name="no_servers_found">Server tidak ditemukan</string> - <string name="subtitle_delay">Penundaan subtitel</string> - <string name="voice_search">Penelusuran suara</string> - <string name="voice_starting">Memulai</string> - <string name="voice_search_prompt">Ucapkan untuk mencari</string> - <string name="processing">Memproses</string> - <string name="press_back_to_cancel">Tekan kembali untuk membatalkan</string> - <string name="voice_error_audio">Error perekaman audio</string> - <string name="voice_error_client">Error pengenalan suara</string> - <string name="voice_error_permissions">Diperlukan izin akses mikrofon</string> - <string name="voice_error_network">Kesalahan jaringan</string> - <string name="voice_error_network_timeout">Koneksi jaringan terputus</string> - <string name="voice_error_no_match">Suara tidak dikenali</string> - <string name="voice_error_busy">Pengenalan suara sedang memproses</string> - <string name="voice_error_server">Gangguan server</string> - <string name="voice_error_speech_timeout">Tidak ada ucapan terdeteksi</string> - <string name="voice_error_unknown">Terjadi kesalahan yang tidak diketahui</string> - <string name="voice_error_start_failed">Gagal memulai pengenalan suara</string> - <string name="voice_error_timeout">Batas waktu pengenalan suara habis</string> - <string name="retry">Coba lagi</string> - <string name="only_forced_subtitles">Hanya Forced Subtitel</string> - <string name="refresh_rate_switching">Peralihan refresh rate</string> - <string name="color_transfer">Transfer warna</string> - <string name="color_primaries">Primari warna</string> - <string name="ref_frames">Frame referensi</string> - <string name="layout">Tata letak</string> - <string name="channels">Saluran</string> - <string name="backdrop_display">Gaya backdrop</string> - <string name="resolution_switching">Peralihan resolusi</string> - <string name="local">Lokal</string> - <string name="play_trailer">Putar trailer</string> - <string name="no_trailers">Tidak ada trailer</string> - <string name="clear_track_choices">Hapus pilihan trek</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">Putar langsung Dolby Vision Profil 7</string> - <string name="force_dovi_profile_7_summary">Abaikan pemeriksaan kompatibilitas perangkat</string> - <string name="discover">Jelajahi</string> - <string name="seerr_integration">Integrasi Seerr</string> - <string name="remove_seerr_server">Hapus Server Seerr</string> - <string name="seerr_server_added">Server Seerr ditambahkan</string> - <string name="password">Kata sandi</string> - <string name="username">Nama pengguna</string> - <string name="url">URL</string> - <string name="trending">Tren</string> - <string name="upcoming_movies">Film Mendatang</string> - <string name="upcoming_tv">Acara TV Mendatang</string> - <string name="request_4k">Minta dalam 4K</string> - <string name="request">Minta</string> - <string name="pending">Tertunda</string> - <string name="software_decoding_av1">Dekode perangkat lunak AV1</string> - <string name="upgrade_mpv_toast">MPV sekarang adalah pemutar bawaan kecuali konten HDR.\nPerubahan dapat dilakukan di menu pengaturan.</string> - <string name="hdr_subtitle_style">Gaya subtitel HDR</string> - <string name="image_subtitle_opacity">Opasitas subtitel gambar</string> - <string name="brightness">Kecerahan</string> - <string name="contrast">Kontras</string> - <string name="saturation">Saturasi</string> - <string name="hue">Hue</string> - <string name="red">Merah</string> - <string name="green">Hijau</string> - <string name="blue">Biru</string> - <string name="blur">Kabur</string> - <string name="save_for_album">Simpan ke album</string> - <string name="play_slideshow">Putar tayangan slide</string> - <string name="stop_slideshow">Hentikan tayangan slide</string> - <string name="slideshow_at_beginning">Di awal</string> - <string name="no_more_images">Tidak ada foto lagi</string> - <string name="rotate_left">Putar kiri</string> - <string name="rotate_right">Putar kanan</string> - <string name="zoom_in">Perbesar</string> - <string name="zoom_out">Perkecil</string> - <string name="slideshow_duration">Durasi tayangan slide</string> - <string name="play_videos_during_slideshow">Putar video selama tayangan slide</string> - <string name="send_media_info_log_to_server">Kirim log info media ke server</string> - <plurals name="days"> - <item quantity="other">%s hari</item> - </plurals> - <string name="quick_connect">Koneksi Cepat</string> - <string name="quick_connect_summary">Izinkan perangkat lain untuk masuk ke akun kamu</string> - <string name="quick_connect_code">Masukkan kode Koneksi Cepat</string> - <string name="quick_connect_code_error">Kode harus terdiri dari 6 digit</string> - <string name="quick_connect_success">Perangkat berhasil diizinkan</string> - <string name="no_limit">Tanpa batasan</string> - <string name="max_days_next_up">Batas hari untuk Berikutnya</string> - <string name="add_row">Tambah baris</string> - <string name="genres_in">Genre di %1$s</string> - <string name="recently_released_in">Baru saja dirilis di %1$s</string> - <string name="height">Tinggi</string> - <string name="apply_all_rows">Terapkan ke semua baris</string> - <string name="customize_home">Kustomisasi beranda</string> - <string name="home_rows">Baris beranda</string> - <string name="load_from_server">Muat dari profil pengguna server</string> - <string name="save_to_server">Simpan ke profil pengguna server</string> - <string name="load_from_web_client">Muat dari klien web</string> - <string name="use_series">Gunakan gambar seri</string> - <string name="add_row_for">Tambah baris untuk %1$s</string> - <string name="overwrite_server_settings">Timpa pengaturan di server?</string> - <string name="overwrite_local_settings">Timpa pengaturan lokal?</string> - <string name="for_episodes">Untuk episode</string> - <string name="suggestions_for">Rekomendasi untuk %1$s</string> - <string name="increase_all_cards_size">Perbesar ukuran semua kartu</string> - <string name="decrease_all_cards_size">Perkecil ukuran semua kartu</string> - <string name="use_thumb_images">Gunakan gambar thumbnail</string> - <string name="settings_saved">Pengaturan disimpan</string> - <string name="display_presets">Preset tampilan</string> - <string name="display_presets_description">Preset bawaan untuk kustomisasi cepat semua baris</string> - <string name="customize_home_summary">Pilih baris dan gambar di halaman beranda</string> - <string name="favorite_items_title">Favoritkan %s</string> - <string name="artist">Artis</string> - <plurals name="minutes"> - <item quantity="other">%s menit</item> - </plurals> - <string name="start_after">Mulai setelah</string> - <string name="animate">Animasi</string> - <string name="max_age_rating">Rating usia maks</string> - <string name="no_max">Tidak ada maks</string> - <string name="for_all_ages">Untuk semua umur</string> - <string name="up_to_age">Maks. usia %s</string> - <string name="screensaver">Screensaver</string> - <string name="screensaver_settings">Pengaturan screensaver</string> - <string name="start_screensaver">Mulai screensaver</string> - <string name="duration">Durasi</string> - <string name="photos">Foto</string> - <string name="include_types">Sertakan jenis</string> - <string name="include_types_summary">Tampilkan gambar untuk jenis item</string> - <string name="display_preset_default">Wolfskin Bawaan</string> - <string name="display_preset_compact">Wolfskin Ringkas</string> - <string name="display_preset_series_thumb">Gambar Thumbnail Seri</string> - <string name="display_preset_episode_thumbnails">Gambar Thumbnail Episode</string> - <string name="volume_lowest">Terendah</string> - <string name="volume_low">Rendah</string> - <string name="volume_medium">Sedang</string> - <string name="volume_high">Tinggi</string> - <string name="volume_full">Penuh</string> - <string name="purple">Ungu</string> - <string name="orange">Oranye</string> - <string name="bold_blue">Biru Tegas</string> - <string name="black">Hitam</string> - <string name="skip_ignore">Abaikan</string> - <string name="skip_automatically">Lewati otomatis</string> - <string name="skip_ask">Tanya sebelum melewati</string> - <string name="ffmpeg_fallback">Gunakan FFmpeg hanya jika dekoder bawaan tidak ada</string> - <string name="ffmpeg_prefer">Utamakan FFmpeg daripada dekoder bawaan</string> - <string name="ffmpeg_never">Jangan pernah gunakan dekoder FFmpeg</string> - <string name="next_up_playback_end">Di akhir pemutaran</string> - <string name="next_up_outro">Selama kredit akhir/outro</string> - <string name="white">Putih</string> - <string name="light_gray">Abu-abu muda</string> - <string name="dark_gray">Abu-abu tua</string> - <string name="yellow">Kuning</string> - <string name="cyan">Sian</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Garis luar</string> - <string name="prefer_mpv">Utamakan MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Gunakan ExoPlayer untuk pemutaran HDR</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">Persegi (1:1)</string> - <string name="image_type_primary">Utama</string> - <string name="image_type_thumb">Thumbnail</string> - <string name="backdrop_style_dynamic">Gambar dengan warna dinamis</string> - <string name="backdrop_style_image">Hanya gambar</string> - <string name="enter_url_api_key"><![CDATA[Masukkan URL & Kunci API]]></string> - <string name="api_key">API Key</string> - <string name="seerr_login">Masuk ke server Seerr</string> - <string name="seerr_jellyfin_user">Pengguna Jellyfin</string> - <string name="seerr_local_user">Pengguna lokal</string> - <string name="search_and_download_subtitles"><![CDATA[Cari & unduh subtitel]]></string> - <string name="no_subtitles_found">Subtitel remote tidak ditemukan</string> - <string name="series_continueing">Tayang</string> - <string name="in_app_screensaver">Gunakan screensaver dalam aplikasi</string> - <string name="actor">Aktor</string> - <string name="composer">Komposer</string> - <string name="writer">Penulis</string> - <string name="guest_star">Bintang tamu</string> - <string name="producer">Produser</string> - <string name="conductor">Konduktor</string> - <string name="lyricist">Penulis lirik</string> - <string name="arranger">Aransemen</string> - <string name="engineer">Teknisi</string> - <string name="mixer">Mixer</string> - <string name="creator">Kreator</string> - <string name="select_all">Pilih semua</string> - <string name="search_for">Cari %s</string> - <string name="show_media_management">Tampilkan opsi kelola media</string> - <string name="delete_item">Apakah kamu yakin ingin menghapus item ini?</string> - <string name="subtitle_edge_shadow">Bayangan</string> - <string name="background_style_wrap">Pembungkus</string> - <string name="background_style_boxed">Kotak</string> - <string name="content_scale_fit">Sesuaikan</string> - <string name="content_scale_crop">Pangkas</string> - <string name="content_scale_fill">Layar penuh</string> - <string name="content_scale_fill_width">Lebar penuh</string> - <string name="content_scale_fill_height">Tinggi penuh</string> - <string name="prefer_logos">Utamakan logo untuk judul</string> - <string name="albums">Album</string> - <string name="artists">Artis</string> - <string name="songs">Lagu</string> - <string name="now_playing">Sedang diputar</string> - <string name="instant_mix">Campuran instan</string> - <string name="add_to_queue">Tambahkan ke antrean</string> - <string name="album_artist">Artis album</string> - <string name="album">Album</string> - <string name="popular_songs">Lagu populer</string> - <string name="play_next">Putar berikutnya</string> - <string name="music_videos">Video musik</string> - <string name="lyrics">Lirik</string> - <string name="hide_lyrics">Sembunyikan lirik</string> - <string name="show_lyrics">Tampilkan lirik</string> - <string name="song_has_lyrics">Tersedia lirik</string> - <string name="remove_from_queue">Hapus dari antrean</string> - <string name="show_album_cover">Tampilkan sampul album</string> - <string name="show_visualizer">Tampilkan visualisator</string> - <string name="show_backdrop">Tampilkan backdrop</string> - <string name="play_as_type">Putar sebagai?</string> - <string name="visualizer_rationale">Memvisualisasikan audio yang sedang diputar memerlukan izin untuk merekam audio.</string> - <string name="continue_string">Lanjutkan</string> - <string name="separate_types">Pisahkan jenis</string> - <string name="go_to_artist">Buka artis</string> - <string name="go_to_album">Buka album</string> - <string name="updated_toast">Wolfskin diperbarui ke %s</string> - <string name="remove_continue_watching">Hapus dari \"Lanjutkan menonton\"</string> - <string name="remove_next_up">Hapus seri dari \"Berikutnya\"</string> - <string name="view_removed_next_up">Lihat seri yang dihapus dari \"Berikutnya\"</string> - <string name="view_more">Lihat semua</string> - <string name="discover_tv">Jelajahi acara TV</string> - <string name="discover_movies">Jelajahi film</string> - <string name="studios_in">Studio dari %1$s</string> - <string name="next_up_never">Tidak pernah</string> - <string name="external_player">Pemutar eksternal</string> - <string name="last_played">Terakhir diputar</string> - <string name="system_default">Default sistem</string> - <string name="ass_subtitle_mode_libass">Putar langsung dengan libass</string> - <string name="ass_subtitle_mode_exoplayer">Putar langsung dengan bawaan ExoPlayer</string> - <string name="ass_subtitle_mode_transcode">Burn-in/transkode di server</string> - <string name="ass_subtitle_playback">Pemutaran subtitle SSA/ASS</string> - <string name="cinema_mode">Mode bioskop</string> - <string name="show_more">Tampilkan %1$s lagi</string> - <string name="transcoding">Transkode</string> - <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="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> - <string name="results">Hasil</string> - <string name="view_options_type_list_compact">Daftar Ringkas</string> - <string name="require_login">Wajibkan login untuk profil</string> - <string name="show_voice_search_button">Tampilkan tombol pencarian suara</string> - <string name="visible_ui">Terlihat</string> - <string name="hidden_ui">Tersembunyi</string> - <string name="view_options_type_grid">Grid</string> - <string name="view_options_type_list">List</string> - <string name="user_interface_language">Bahasa antarmuka pengguna</string> - <string name="display_toggles_title">Tampilan rating</string> - <string name="display_toggles_summary">Sesuaikan item yang akan ditampilkan</string> - <string name="profile_protection">Perlindungan profil</string> - <string name="continue_watching_click_behavior">Perilaku klik Lanjut menonton/Berikutnya</string> - <string name="continue_watching_click_summary_on">Putar saat diklik</string> - <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> - <plurals name="episodes_count"> - <item quantity="other">%1$d episode</item> - </plurals> - <string name="prefer_dovi_over_hdr10_plus">Utamakan Dolby Vision dibanding HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+</string> - <string name="remove_from_playlist">Hapus dari playlist</string> - <string name="login_credentials_expired">Kredensial kedaluwarsa, silakan login kembali</string> - <string name="login_not_authorized">Kamu tidak diizinkan mengakses server saat ini</string> - <string name="max_resolution">Resolusi maksimum sebelum transkode</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml deleted file mode 100644 index be2f53e3..00000000 --- a/app/src/main/res/values-it/strings.xml +++ /dev/null @@ -1,782 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="chapters">Capitoli</string> - <string name="director">Regista</string> - <string name="delete">Elimina</string> - <string name="died">Morto</string> - <string name="continue_watching">Continua a guardare</string> - <string name="cancel">Annulla</string> - <string name="cancel_series_recording">Annulla registrazione serie</string> - <string name="born">Nato</string> - <string name="cancel_recording">Annulla registrazione</string> - <string name="audio">Audio</string> - <string name="birthplace">Luogo di nascita</string> - <string name="bitrate">Bitrate</string> - <string name="collection">Collezione</string> - <string name="collections">Collezioni</string> - <string name="add_user">Aggiungi Utente</string> - <string name="add_server">Aggiungi Server</string> - <string name="add_favorite">Preferito</string> - <string name="active_recordings">Registrazioni attive</string> - <string name="about">Informazioni</string> - <string name="genres">Generi</string> - <string name="favorites">Preferiti</string> - <string name="confirm">Conferma</string> - <string name="choose_stream">Scegli %1$s</string> - <string name="clear_image_cache">Pulisci cache immagini</string> - <string name="compose_error_message_title">Si è verificato un errore! Premi il pulsante per inviare i log al tuo server.</string> - <string name="directed_by">Diretto da %1$s</string> - <string name="enabled">Abilitato</string> - <string name="episodes">Episodi</string> - <string name="file_size">Dimensione</string> - <string name="go_to_series">Vai alla serie</string> - <string name="hide">Nascondi</string> - <string name="loading">Caricamento…</string> - <string name="library">Libreria</string> - <string name="live_tv">Diretta TV</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> - <string name="mark_watched">Segna come visto</string> - <string name="play">Riproduci</string> - <string name="playback_speed">Velocità di riproduzione</string> - <string name="playback">Riproduzione</string> - <string name="playlist">Playlist</string> - <string name="playlists">Le Playlist</string> - <string name="preview">Anteprima</string> - <string name="profile_specific_settings">Impostazioni profilo utente</string> - <string name="queue">Coda</string> - <string name="recap">Riassunto</string> - <string name="recently_added_in">Aggiunti di recente in %1$s</string> - <string name="recently_added">Aggiunti di recente</string> - <string name="recently_recorded">Registrati di recente</string> - <string name="recently_released">Usciti di recente</string> - <string name="recommended">Consigliati</string> - <string name="record_program">Registra programma</string> - <string name="record_series">Registra serie</string> - <string name="remove_favorite">Rimuovi dai preferiti</string> - <string name="restart">Riavvia</string> - <string name="resume">Riprendi</string> - <string name="save">Salva</string> - <string name="search_and_download"><![CDATA[Cerca & Scarica]]></string> - <string name="search">Cerca</string> - <string name="select_server">Seleziona Server</string> - <string name="select_user">Seleziona Utente</string> - <string name="skip">Salta</string> - <string name="subtitle">Sottotitolo</string> - <string name="subtitles">Sottotitoli</string> - <string name="sort_by_random">Casuale</string> - <string name="tv_season">Stagione</string> - <string name="tv_seasons">Stagioni</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> - <string name="version">Versione</string> - <string name="create_playlist">Crea nuova playlist</string> - <string name="add_to_playlist">Aggiungi alla playlist</string> - <string name="one_click_pause">Pausa con un click</string> - <string name="show_clock">Mostra Orologio</string> - <string name="font">Carattere</string> - <string name="background">Sfondo</string> - <plurals name="behind_the_scenes"> - <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> - </plurals> - <plurals name="interviews"> - <item quantity="one">Intervista</item> - <item quantity="many">Interviste</item> - <item quantity="other">Interviste</item> - </plurals> - <plurals name="scenes"> - <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> - <string name="show">Mostra</string> - <string name="searching">Ricerca in corso…</string> - <string name="decimal_seconds">%.2f secondi</string> - <string name="commercial">Commerciale</string> - <string name="community_rating">Valutazione community</string> - <string name="critic_rating">Valutazione della critica</string> - <string name="default_track">Predefinita</string> - <string name="discovered_servers">Server Scoperti</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> - <string name="hide_controller_timeout">Nascondi controlli di riproduzione</string> - <string name="external_track">Esterna</string> - <string name="enter_server_url">Inserisci Server IP o URL</string> - <string name="error_loading_collection">Errore caricamento collezione %1$s</string> - <string name="disabled">Disabilitato</string> - <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_title">Extra</string> - <plurals name="other_extras"> - <item quantity="one">Altro</item> - <item quantity="many">Altri</item> - <item quantity="other">Altri</item> - </plurals> - <string name="bold_font">Testo in grassetto</string> - <string name="player_backend">Backend di riproduzione</string> - <string name="mpv_hardware_decoding">MPV: Usa decodifica hardware</string> - <string name="disable_if_crash">Disabilita se riscontri crash</string> - <string name="mpv_options">Opzioni MPV</string> - <string name="exoplayer_options">Opzioni ExoPlayer</string> - <string name="skip_segment_unknown">Salta Segmento</string> - <string name="skip_segment_commercial">Salta Pubblicità</string> - <string name="skip_segment_preview">Salta Anteprima</string> - <string name="skip_segment_intro">Salta Intro</string> - <string name="played">Riprodotto</string> - <string name="filter">Filtra</string> - <string name="year">Anno</string> - <string name="decade">Decennio</string> - <string name="remove">Rimuovi</string> - <string name="install_update">Installa aggiornamento</string> - <string name="installed_version">Versione installata</string> - <string name="license_info">Informazioni Licenza</string> - <string name="intro">Intro</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="mark_entire_series_as_played">Segnare tutta la serie come vista?</string> - <string name="mark_entire_series_as_unplayed">Segnare tutta la serie come non vista?</string> - <string name="home">Home</string> - <string name="more_like_this">Simili a questo</string> - <string name="more">Altro</string> - <string name="play_from_here">Riproduci da qui</string> - <string name="playback_debug_info">Mostra info debug riproduzione</string> - <string name="path">Percorso</string> - <string name="play_count">Numero di riproduzioni</string> - <string name="no_update_available">Nessun aggiornamento disponibile</string> - <string name="none">Nessuno</string> - <string name="outro">Outro</string> - <string name="no_data">Nessun dato</string> - <string name="name">Nome</string> - <string name="next_up">Prossimo Episodio</string> - <string name="no_scheduled_recordings">Nessuna registrazione programmata</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> - <string name="sort_by_name">Nome</string> - <string name="submit">Invia</string> - <string name="subtitle_download_too_long">Il download sta impiegando molto tempo, potrebbe essere necessario riavviare la riproduzione</string> - <string name="suggestions">Suggerimenti</string> - <string name="switch_servers">Cambia server</string> - <string name="switch_user">Cambia</string> - <plurals name="trailers"> - <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> - <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> - <string name="shuffle">Mescola</string> - <string name="sort_by_date_episode_added">Data Aggiunta Episodio</string> - <string name="sort_by_date_released">Data di uscita</string> - <string name="studios">Studio</string> - <string name="top_unwatched">I Più Votati Non visti</string> - <string name="aired_episode_order">Ordine di trasmissione</string> - <string name="one_click_pause_summary_on">Premi tasto centrale D-Pad per pausa/play</string> - <string name="italic_font">Testo in corsivo</string> - <string name="success">Successo</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="background_color">Colore sfondo</string> - <string name="reset">Ripristina</string> - <string name="subtitle_style">Stile sottotitoli</string> - <string name="background_style">Stile sfondo</string> - <string name="settings">Impostazioni</string> - <string name="update_url">Aggiorna URL</string> - <string name="font_size">Dimensione testo</string> - <string name="update_available">Aggiornamento disponibile</string> - <string name="update_url_summary">URL usato per cercare aggiornamenti dell\'app</string> - <string name="edge_style">Stile del bordo</string> - <string name="edge_color">Colore del bordo</string> - <string name="background_opacity">Opacità sfondo</string> - <string name="skip_segment_outro">Salta Outro</string> - <plurals name="theme_videos"> - <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">Scena Eliminata</item> - <item quantity="many">Scene Eliminate</item> - <item quantity="other">Scene Eliminate</item> - </plurals> - <plurals name="shorts"> - <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> - <string name="combine_continue_next_summary">Applica solo a Serie TV</string> - <string name="app_theme">Tema Applicazione</string> - <string name="auto_check_for_updates">Cerca automaticamente aggiornamenti</string> - <string name="auto_play_next">Riproduci automaticamente il prossimo episodio</string> - <string name="direct_play_ass">Usa libass per i sottotitoli ASS</string> - <string name="direct_play_pgs">Riproduzione diretta sottotitoli PGS</string> - <string name="ffmpeg_extension_pref">Usa il modulo di decodifica FFmpeg</string> - <string name="play_theme_music">Riproduci musica tema</string> - <plurals name="hours"> - <item quantity="one">%d ora</item> - <item quantity="many">%d ore</item> - <item quantity="other">%d ore</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d elemento</item> - <item quantity="many">%d elementi</item> - <item quantity="other">%d elementi</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d secondo</item> - <item quantity="many">%d secondi</item> - <item quantity="other">%d secondi</item> - </plurals> - <string name="remember_selected_tab">Ricorda schede selezionate</string> - <plurals name="samples"> - <item quantity="one">Campione</item> - <item quantity="many">Campioni</item> - <item quantity="other">Campioni</item> - </plurals> - <plurals name="featurettes"> - <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> - <string name="max_homepage_items">Numero massimo di elementi per riga nella home</string> - <string name="global_content_scale">Scala contenuti predefinita</string> - <string name="official_rating">Classificazione per età</string> - <string name="runtime_sort">Durata</string> - <plurals name="clips"> - <item quantity="one">Clip</item> - <item quantity="many">Clip</item> - <item quantity="other">Clip</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s scaricamento</item> - <item quantity="many">%s scaricamenti</item> - <item quantity="other">%s scaricamenti</item> - </plurals> - <string name="ac3_supported">Il dispositivo supporta AC3/Dolby Digital</string> - <string name="advanced_ui">Interfaccia avanzata</string> - <string name="auto_play_next_delay">Ritardo prima della riproduzione del prossimo episodio</string> - <string name="nav_drawer_pins_summary">Scegli gli elementi predefiniti da mostrare, gli altri saranno nascosti</string> - <string name="nav_drawer_pins">Personalizza gli elementi del menu di navigazione</string> - <string name="nav_drawer_switch_on_focus_summary_off">Clicca per cambiare pagina</string> - <string name="nav_drawer_switch_on_focus">Cambia pagina del menu di navigazione al focus</string> - <string name="pass_out_protection">Protezione dallo spegnimento</string> - <string name="seek_bar_steps">Incrementi della barra di avanzamento</string> - <string name="send_app_logs_summary">Utile per il debug</string> - <string name="send_app_logs">Invia i log dell\'app al server corrente</string> - <string name="send_crash_reports_summary">Proverà a inviare all\'ultimo server connesso</string> - <string name="send_crash_reports">Invia segnalazioni di crash</string> - <string name="rewatch_next_up">Abilita la ripetizione in Prossimo episodio</string> - <string name="skip_back_preference">Torna indietro</string> - <string name="skip_commercials_behavior">Comportamento salto pubblicità</string> - <string name="skip_forward_preference">Salta avanti</string> - <string name="skip_intro_behavior">Comportamento salto intro</string> - <string name="skip_outro_behavior">Comportamento salto outro</string> - <string name="skip_previews_behavior">Comportamento salto anteprime</string> - <string name="skip_recap_behavior">Comportamento salto riassunto</string> - <string name="skip_segment_recap">Salta Riassunto</string> - <string name="playback_overrides">Sovrascrivi riproduzione</string> - <string name="skip_back_on_resume_preference">Salta indietro al riavvio della riproduzione</string> - <string name="subtitle_margin">Margine</string> - <string name="verbose_logging">Log dettagliati</string> - <string name="image_cache_size">Dimensione cache Immagini su disco (MB)</string> - <string name="mpv_use_gpu_next">MPV: Usa gpu-next</string> - <string name="mpv_conf">Modifica mpv.conf</string> - <string name="discard_change">Annullare le modifiche?</string> - <string name="enter_pin">Inserisci PIN</string> - <string name="sign_in_auto">Accedi automaticamente</string> - <string name="use_server_credentials">Accedi tramite server</string> - <string name="press_enter_to_confirm">Premi centro per confermare</string> - <string name="require_pin_code">Richiedi PIN per il profilo</string> - <string name="confirm_pin">Conferma PIN</string> - <string name="incorrect">Errato</string> - <string name="pin_too_short">Il PIN deve essere di 4 cifre o più</string> - <string name="will_remove_pin">Rimuoverà il PIN</string> - <string name="view_options">Opzioni di visualizzazione</string> - <string name="columns">Colonne</string> - <string name="spacing">Spaziatura</string> - <string name="show_details">Mostra dettagli</string> - <string name="aspect_ratio">Rapporto d’aspetto</string> - <string name="image_type">Tipo di immagine</string> - <string name="cast_and_crew"><![CDATA[Cast e troupe]]></string> - <string name="guest_stars">Guest star</string> - <string name="edge_size">Dimensione del bordo</string> - <string name="login">Accedi</string> - <string name="automatic">Automatico</string> - <string name="refresh_rate_switching">Cambio frequenza di aggiornamento</string> - <string name="username_or_password">Usa nome utente/password</string> - <string name="media_information">Informazioni sui media</string> - <string name="general">Generali</string> - <string name="title">Titolo</string> - <string name="codec">Codifica</string> - <string name="profile">Profilo</string> - <string name="level">Livello</string> - <string name="resolution">Risoluzione</string> - <string name="anamorphic">Anamorfico</string> - <string name="interlaced">Interlacciato</string> - <string name="video_range">Range del video</string> - <string name="color_space">Spazio colore</string> - <string name="color_transfer">trasferimento colore</string> - <string name="color_primaries">Colori primari</string> - <string name="pixel_format">Formato pixel</string> - <string name="language">Lingua</string> - <string name="channels">Canali</string> - <string name="yes">Si</string> - <string name="no">No</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">Mostra titoli</string> - <string name="avc">AVC</string> - <string name="framerate">Frequenza fotogrammi</string> - <string name="bit_depth">Profondità bit</string> - <string name="container">Contenitore</string> - <string name="video_range_type">Tipo di gamma video</string> - <string name="nal">NAL</string> - <string name="sample_rate">Frequenza di campionamento</string> - <string name="ref_frames">Frame di riferimento</string> - <string name="layout">Disposizione</string> - <string name="live_tv_repeat">Ripeti</string> - <string name="favorite_channels_at_beginning">Mostra prima i canali preferiti</string> - <string name="sort_channels_recently_watched">Ordina canali per ultimo guardato</string> - <string name="color_code_programs">Codifica colori per i programmi</string> - <string name="subtitle_delay">Ritardo sottotitoli</string> - <string name="enter_server_address">Inserisci indirizzo server</string> - <string name="no_servers_found">Nessun server trovato</string> - <string name="backdrop_display">Stile backdrop</string> - <string name="ends_at">Termina alle %1$s</string> - <string name="resolution_switching">Cambio risoluzione</string> - <string name="local">Locale</string> - <string name="play_trailer">Riproduci trailer</string> - <string name="no_trailers">Nessun trailer</string> - <string name="clear_track_choices">Cancella scelte tracce</string> - <string name="only_forced_subtitles">Solo sottotitoli forzati</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">Riproduzione diretta Dolby Vision Profilo 7</string> - <string name="force_dovi_profile_7_summary">Ignora le verifiche di compatibilità del dispositivo</string> - <string name="discover">Scopri</string> - <string name="request">Richiedi</string> - <string name="pending">In attesa</string> - <string name="seerr_integration">Integrazione Seerr</string> - <string name="remove_seerr_server">Rimuovi server Seerr</string> - <string name="password">Password</string> - <string name="username">Nome utente</string> - <string name="url">URL</string> - <string name="trending">In tendenza</string> - <string name="upcoming_movies">Film in arrivo</string> - <string name="upcoming_tv">Serie TV in arrivo</string> - <string name="request_4k">Richiedi in 4K</string> - <string name="seerr_server_added">Server Seerr aggiunto</string> - <string name="voice_search">Ricerca vocale</string> - <string name="voice_starting">Avvio in corso</string> - <string name="voice_search_prompt">Parla per cercare</string> - <string name="processing">Elaborazione in corso</string> - <string name="press_back_to_cancel">Premi indietro per annullare</string> - <string name="voice_error_audio">Errore registrazione audio</string> - <string name="voice_error_client">Errore riconoscimento vocale</string> - <string name="voice_error_permissions">Autorizzazione microfono richiesta</string> - <string name="voice_error_network">Errore di rete</string> - <string name="voice_error_network_timeout">Timeout di rete</string> - <string name="voice_error_no_match">Nessun discorso riconosciuto</string> - <string name="voice_error_busy">Riconoscimento vocale occupato</string> - <string name="voice_error_server">Errore server</string> - <string name="voice_error_speech_timeout">Nessun discorso rilevato</string> - <string name="voice_error_unknown">Errore sconosciuto</string> - <string name="voice_error_start_failed">Impossibile avviare il riconoscimento vocale</string> - <string name="retry">Riprova</string> - <string name="voice_error_timeout">Riconoscimento vocale scaduto</string> - <string name="software_decoding_av1">Decodifica software AV1</string> - <string name="upgrade_mpv_toast">MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni.</string> - <string name="hdr_subtitle_style">Stile sottotitoli HDR</string> - <string name="image_subtitle_opacity">Opacità sottotitoli immagine</string> - <string name="brightness">Luminosità</string> - <string name="contrast">Contrasto</string> - <string name="saturation">Saturazione</string> - <string name="hue">Tinta</string> - <string name="red">Rosso</string> - <string name="green">Verde</string> - <string name="blue">Blu</string> - <string name="blur">Sfocatura</string> - <string name="save_for_album">Salva per l\'album</string> - <string name="play_slideshow">Riproduci slideshow</string> - <string name="stop_slideshow">Ferma slideshow</string> - <string name="slideshow_at_beginning">Dall\'inizio</string> - <string name="no_more_images">Nessun\'altra foto</string> - <string name="rotate_left">Ruota a sinistra</string> - <string name="rotate_right">Ruota a destra</string> - <string name="zoom_in">Ingrandisci</string> - <string name="zoom_out">Rimpicciolisci</string> - <string name="slideshow_duration">Durata dello slideshow</string> - <string name="play_videos_during_slideshow">Riproduci i video durante lo slideshow</string> - <string name="send_media_info_log_to_server">Invia log informazioni media al server</string> - <plurals name="days"> - <item quantity="one">%s giorno</item> - <item quantity="many">%s giorni</item> - <item quantity="other">%s giorni</item> - </plurals> - <string name="no_limit">Nessun limite</string> - <string name="max_days_next_up">Giorni massimi in Prossimo</string> - <string name="quick_connect">Connessione rapida</string> - <string name="quick_connect_summary">Autorizza un altro dispositivo per accedere al tuo account</string> - <string name="quick_connect_code">Inserisci il codice di Connessione rapida</string> - <string name="quick_connect_code_error">Il codice deve essere di 6 cifre</string> - <string name="quick_connect_success">Dispositivo autorizzato con successo</string> - <string name="add_row">Aggiungi riga</string> - <string name="genres_in">Generi in %1$s</string> - <string name="recently_released_in">Recentemente rilasciato in %1$s</string> - <string name="height">Altezza</string> - <string name="apply_all_rows">Applica a tutte le righe</string> - <string name="load_from_server">Carica dal profilo utente del server</string> - <string name="add_row_for">Aggiungi riga per %1$s</string> - <string name="overwrite_server_settings">Sovrascrivere le impostazioni sul server?</string> - <string name="overwrite_local_settings">Sovrascrivere le impostazioni locali?</string> - <string name="suggestions_for">Suggerimenti per %1$s</string> - <string name="increase_all_cards_size">Aumenta dimensione di tutte le card</string> - <string name="decrease_all_cards_size">Riduci dimensione di tutte le card</string> - <string name="use_thumb_images">Usa immagini thumbnail</string> - <string name="settings_saved">Impostazioni salvate</string> - <string name="display_presets">Mostra preset</string> - <string name="display_presets_description">Preset integrati per stilizzare rapidamente tutte le righe</string> - <string name="home_rows">Righe home</string> - <string name="for_episodes">Per gli episodi</string> - <string name="customize_home">Personalizza home page</string> - <string name="save_to_server">Salva nel profilo utente del server</string> - <string name="load_from_web_client">Carica dal client web</string> - <string name="use_series">Usa immagine delle serie</string> - <string name="customize_home_summary">Scegli righe e immagini nella home page</string> - <string name="favorite_items_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> - <string name="content_scale_fill_width">Riempi larghezza</string> - <string name="content_scale_fill_height">Riempi altezza</string> - <string name="display_preset_default">Wolfskin predefinito</string> - <string name="display_preset_compact">Wolfskin Compatto</string> - <string name="display_preset_series_thumb">Immagini miniatura serie</string> - <string name="display_preset_episode_thumbnails">Immagini miniatura episodio</string> - <string name="volume_lowest">Minimo</string> - <string name="volume_low">Basso</string> - <string name="volume_medium">Medio</string> - <string name="volume_high">Alto</string> - <string name="volume_full">Massimo</string> - <string name="purple">Viola</string> - <string name="orange">Arancione</string> - <string name="bold_blue">Blu intenso</string> - <string name="black">Nero</string> - <string name="skip_ignore">Ignora</string> - <string name="skip_automatically">Salta automaticamente</string> - <string name="skip_ask">Chiedi se saltare</string> - <string name="ffmpeg_fallback">Usa FFmpeg solo se non esiste un decodificatore integrato</string> - <string name="ffmpeg_prefer">Preferisci FFmpeg ai decoder integrati</string> - <string name="ffmpeg_never">Non usare mai i decoder FFmpeg</string> - <string name="next_up_playback_end">Alla fine della riproduzione</string> - <string name="next_up_outro">Durante i titoli di coda</string> - <string name="white">Bianco</string> - <string name="light_gray">Grigio chiaro</string> - <string name="dark_gray">Grigio scuro</string> - <string name="yellow">Giallo</string> - <string name="cyan">Ciano</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contorno</string> - <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 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> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Quadrato (1:1)</string> - <string name="image_type_primary">Principale</string> - <string name="image_type_thumb">Miniatura</string> - <string name="backdrop_style_dynamic">Immagine con colore dinamico</string> - <string name="backdrop_style_image">Solo immagine</string> - <string name="enter_url_api_key"><![CDATA[Inserisci URL e chiave API]]></string> - <string name="api_key">Chiave API</string> - <string name="seerr_login">Accedi al server Seerr</string> - <string name="seerr_jellyfin_user">Utente Jellyfin</string> - <string name="seerr_local_user">Utente locale</string> - <string name="search_and_download_subtitles"><![CDATA[Cerca e scarica sottotitoli]]></string> - <string name="no_subtitles_found">Nessun sottotitolo remoto trovato</string> - <string name="series_continueing">In corso</string> - <plurals name="minutes"> - <item quantity="one">%s minuto</item> - <item quantity="many">%s minuti</item> - <item quantity="other">%s minuti</item> - </plurals> - <string name="start_after">Inizia dopo</string> - <string name="animate">Anima</string> - <string name="max_age_rating">Età massima</string> - <string name="for_all_ages">Per tutte le età</string> - <string name="up_to_age">Fino a %s anni</string> - <string name="screensaver">Salvaschermo</string> - <string name="screensaver_settings">Impostazioni salvaschermo</string> - <string name="start_screensaver">Avvia salvaschermo</string> - <string name="duration">Durata</string> - <string name="photos">Foto</string> - <string name="include_types">Includi tipi</string> - <string name="include_types_summary">Quali tipi di elementi mostrare nelle immagini</string> - <string name="no_max">Nessun massimo</string> - <string name="in_app_screensaver">Usa salvaschermo dell\'app</string> - <string name="composer">Compositore</string> - <string name="writer">Sceneggiatore</string> - <string name="actor">Attore</string> - <string name="producer">Produttore</string> - <string name="guest_star">Ospite speciale</string> - <string name="conductor">Direttore d’orchestra</string> - <string name="lyricist">Paroliere</string> - <string name="arranger">Arrangiatore</string> - <string name="engineer">Ingegnere del suono</string> - <string name="mixer">Tecnico di mixaggio</string> - <string name="artist">Artista</string> - <string name="creator">Creatore</string> - <string name="search_for">Cerca %s</string> - <string name="delete_item">Sei sicuro di voler cancellare questo elemento?</string> - <string name="show_media_management">Mostra opzioni di gestione dei media</string> - <string name="select_all">Seleziona tutti</string> - <string name="albums">Album</string> - <string name="artists">Artisti</string> - <string name="songs">Canzoni</string> - <string name="go_to_artist">Vai all\'artista</string> - <string name="now_playing">In riproduzione</string> - <string name="instant_mix">Mix istantaneo</string> - <string name="go_to_album">Vai all\'album</string> - <string name="add_to_queue">Aggiungi alla coda</string> - <string name="album_artist">Album artista</string> - <string name="album">Album</string> - <string name="popular_songs">Canzoni popolari</string> - <string name="play_next">Riproduci successivo</string> - <string name="music_videos">Video musicali</string> - <string name="lyrics">Testi</string> - <string name="hide_lyrics">Nascondi testi</string> - <string name="show_lyrics">Mostra testi</string> - <string name="song_has_lyrics">La canzone ha i testi</string> - <string name="remove_from_queue">Rimuovi dalla coda</string> - <string name="show_album_cover">Mostra copertina album</string> - <string name="show_visualizer">Mostra visualizzatore</string> - <string name="show_backdrop">Mostra sfondo</string> - <string name="play_as_type">Riproduci come?</string> - <string name="visualizer_rationale">Per visualizzare l’audio in riproduzione è necessario il permesso di registrare l’audio.</string> - <string name="continue_string">Continua</string> - <string name="separate_types">Tipi separati</string> - <string name="prefer_logos">Preferisci mostrare i loghi per i titoli</string> - <string name="updated_toast">Wolfskin aggiornato a %s</string> - <string name="remove_continue_watching">Rimuovi da Continua a guardare</string> - <string name="remove_next_up">Rimuovi serie da Prossimo</string> - <string name="view_removed_next_up">Visualizza serie rimosse da Prossimo</string> - <string name="view_more">Mostra altro</string> - <string name="discover_tv">Scopri serie TV</string> - <string name="discover_movies">Scopri film</string> - <string name="studios_in">Studi in %1$s</string> - <string name="ass_subtitle_mode_libass">Riproduzione diretta con libass</string> - <string name="ass_subtitle_mode_exoplayer">Riproduzione diretta con ExoPlayer integrato</string> - <string name="ass_subtitle_mode_transcode">Masterizza/trascodifica sul server</string> - <string name="ass_subtitle_playback">Riproduzione sottotitoli SSA/ASS</string> - <string name="next_up_never">Mai</string> - <string name="external_player">Player esterno</string> - <string name="system_default">Predefinito sistema</string> - <string name="last_played">Ultima riproduzione</string> - <string name="cinema_mode">Modalità cinema</string> - <string name="show_more">Mostra %1$s altri</string> - <string name="skip_behavior">Comportamento di salto</string> - <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="results">Risultati</string> - <string name="require_login">Richiedi login per il profilo</string> - <string name="combined_search_results">Risultati di ricerca combinati</string> - <string name="combined_search_results_on">Mostra tutti i risultati in una lista singola</string> - <string name="combined_search_results_off">Mostra risultati per categoria</string> - <string name="profile_protection">Protezione profilo</string> - <string name="continue_watching_click_summary_on">Riproduci al click</string> - <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> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Episodio</item> - <item quantity="many">%1$d Episodi</item> - <item quantity="other">%1$d Episodi</item> - </plurals> - <string name="prefer_dovi_over_hdr10_plus">Preferisci Dolby Vision a HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+</string> - <string name="max_resolution">Risoluzione massima prima della trascodifica</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="top_rated">I più votati</string> - <string name="hdr_formats">Formati HDR</string> - <string name="dovi_profile_5">Dolby Vision Profilo 5</string> - <string name="dovi_profile_7">Dolby Vision Profilo 7</string> - <string name="dovi_profile_8">Dolby Vision Profilo 8</string> - <string name="force_enable">Forza abilitazione</string> - <string name="force_disable">Forza disabilitazione</string> - <string name="server_version_not_supported_future">La versione del server non sarà più supportata a breve</string> - <string name="server_version_required">Versione del server richiesta: %1$s</string> - <string name="series">Serie</string> - <string name="error">Errore</string> - <string name="user">Utente</string> - <string name="load_from_server_plugin">Carica predefinito dal server</string> - <string name="save_to_local">Salva solo su questo dispositivo</string> - <string name="home_settings_source_unset">Predefinito</string> - <string name="home_settings_source_local">Specifico del dispositivo</string> - <string name="home_settings_source_server_profile">Profilo utente del server</string> - <string name="home_settings_source_plugin">Predefinito del server</string> - <string name="home_settings_source_web">Client web utente</string> - <string name="managed_by_server">Gestito dal server</string> - <string name="chosen_by">Scelto da %1$s</string> -</resources> diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml deleted file mode 100644 index d05db022..00000000 --- a/app/src/main/res/values-iw/strings.xml +++ /dev/null @@ -1,759 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">אודות</string> - <string name="active_recordings">הקלטות פעילות</string> - <string name="add_favorite">הוספה למועדפים</string> - <string name="add_server">הוסף שרת</string> - <string name="add_user">הוסף משתמש</string> - <string name="audio">שמע</string> - <string name="birthplace">מקום לידה</string> - <string name="bitrate">קצב נתונים</string> - <string name="cancel_recording">בטל הקלטה</string> - <string name="cancel_series_recording">בטל הקלטת סדרה</string> - <string name="cancel">בטל</string> - <string name="chapters">סצינות</string> - <string name="clear_image_cache">נקה זיכרון מטמון</string> - <string name="collection">אוסף</string> - <string name="collections">אוספים</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="critic_rating">דירוג מבקרים</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="disabled">מושבת</string> - <string name="born">נולד</string> - <string name="choose_stream">בחר %1$s</string> - <string name="select_server">בחר שרת</string> - <string name="discovered_servers">שרתים שנמצאו</string> - <string name="searching">מחפש…</string> - <string name="enter_server_address">הזן כתובת שרת</string> - <string name="select_user">בחר משתמש</string> - <string name="switch_servers">החלף שרתים</string> - <string name="search">חיפוש</string> - <string name="home">דף הבית</string> - <string name="favorites">מועדפים</string> - <plurals name="movies"> - <item quantity="one">סרט</item> - <item quantity="two">סרטים</item> - <item quantity="other">סרטים</item> - </plurals> - <plurals name="tv_shows"> - <item quantity="one">סדרה</item> - <item quantity="two">סדרות</item> - <item quantity="other">סדרות</item> - </plurals> - <string name="tv_seasons">עונות</string> - <string name="tv_season">עונה</string> - <plurals name="trailers"> - <item quantity="one">קדימון</item> - <item quantity="two">קדימונים</item> - <item quantity="other">קדימונים</item> - </plurals> - <string name="subtitle">כתובית</string> - <string name="subtitles">כתוביות</string> - <string name="ui_interface">ממשק</string> - <string name="sign_in_auto">התחבר אוטומטית</string> - <string name="remember_selected_tab">זכור כרטיסיות שנבחרו</string> - <string name="enabled">מופעל</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="white">לבן</string> - <string name="login">התחבר</string> - <string name="username">שם משתמש</string> - <string name="password">סיסמה</string> - <string name="downloading">מוריד…</string> - <string name="ends_at">מסתיים ב־%1$s</string> - <string name="enter_server_url">הזן כתובת IP או URL</string> - <string name="episodes">פרקים</string> - <string name="external_track">חיצוני</string> - <string name="file_size">גודל</string> - <string name="genres">ז\'אנרים</string> - <string name="go_to_series">עבור לסדרה</string> - <string name="go_to">עבור אל</string> - <string name="hide">הסתר</string> - <string name="error_loading_collection">שגיאה בטעינת האוסף %1$s</string> - <string name="intro">פתיח</string> - <string name="library">תכנים</string> - <string name="more">עוד</string> - <string name="name">שם</string> - <string name="next_up">הבא בתור</string> - <string name="no_data">אין נתונים</string> - <string name="no_results">אין תוצאות</string> - <string name="no_servers_found">לא נמצאו שרתים</string> - <string name="outro">סיום</string> - <string name="none">ללא</string> - <plurals name="people"> - <item quantity="one">איש</item> - <item quantity="two">אנשים</item> - <item quantity="other">אנשים</item> - </plurals> - <string name="play_from_here">נגן מכאן</string> - <string name="play">נגן</string> - <string name="playback_speed">מהירות ניגון</string> - <string name="playback">ניגון</string> - <string name="playlist">רשימת־השמעה</string> - <string name="playlists">רשימות־השמעה</string> - <plurals name="downloads"> - <item quantity="one">הורדה %s</item> - <item quantity="two">%s הורדות</item> - <item quantity="other">%s הורדות</item> - </plurals> - <plurals name="hours"> - <item quantity="one">שעה %d</item> - <item quantity="two">שעתיים</item> - <item quantity="other">%d שעות</item> - </plurals> - <plurals name="days"> - <item quantity="one">יום %s</item> - <item quantity="two">יומיים</item> - <item quantity="other">%s ימים</item> - </plurals> - <plurals name="items"> - <item quantity="one">פריט %d</item> - <item quantity="two">%d פריטים</item> - <item quantity="other">%d פריטים</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">שניה %d</item> - <item quantity="two">%d שניות</item> - <item quantity="other">%d שניות</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">דקה %s</item> - <item quantity="two">%s דקות</item> - <item quantity="other">%s דקות</item> - </plurals> - <string name="mark_unwatched">סמן כלא נצפה</string> - <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="forced_track">כפוי</string> - <string name="only_forced_subtitles">כתוביות כפויות בלבד</string> - <string name="hide_controller_timeout">הסתר בקרי ניגון</string> - <string name="hide_debug_info">הסתר מידע לניפוי־שגיאות</string> - <string name="immediate">מיידי</string> - <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="no_scheduled_recordings">אין הקלטות מתוזמנות</string> - <string name="no_update_available">אין עדכון זמין</string> - <string name="path">נתיב</string> - <string name="play_count">מספר ניגונים</string> - <string name="playback_debug_info">הצג מידע לניפוי־שגיאות על הניגון</string> - <string name="preview">תצוגה מקדימה</string> - <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_recorded">הוקלט לאחרונה</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="restart">התחל מחדש</string> - <string name="resume">המשך ניגון</string> - <string name="save">שמור</string> - <string name="search_and_download"><![CDATA[חיפוש והורדה]]></string> - <string name="voice_search">חיפוש קולי</string> - <string name="voice_search_prompt">חפש בקולך</string> - <string name="processing">מעבד</string> - <string name="press_back_to_cancel">לחץ חזור כדי לבטל</string> - <string name="voice_error_audio">שגיאה בהקלטת שמע</string> - <string name="voice_error_client">שגיאה בזיהוי דיבור</string> - <string name="voice_error_permissions">דרושה הרשאה למיקרופון</string> - <string name="voice_error_network">שגיאת רשת</string> - <string name="voice_error_network_timeout">הרשת לא הגיבה בזמן</string> - <string name="voice_error_speech_timeout">לא זוהה דיבור</string> - <string name="voice_error_timeout">הזמן לזיהוי דיבור פג</string> - <string name="voice_error_no_match">הדיבור לא הובן</string> - <string name="voice_error_busy">זיהוי דיבור לא זמין</string> - <string name="voice_error_server">שגיאת שרת</string> - <string name="voice_error_unknown">שגיאה לא ידועה</string> - <string name="voice_error_start_failed">נכשל באתחול זיהוי הדיבור</string> - <string name="retry">נסה שוב</string> - <string name="show_debug_info">הצג מידע לניפוי־שגיאות</string> - <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="sort_by_date_played">תאריך ניגון</string> - <string name="sort_by_date_released">תאריך יציאה</string> - <string name="sort_by_name">שם</string> - <string name="sort_by_random">אקראי</string> - <string name="studios">אולפנים</string> - <string name="submit">שלח</string> - <string name="voice_starting">מאתחל</string> - <string name="subtitle_download_too_long">ההורדה לוקחת זמן רב, ייתכן שיצטרכו לנגן מחדש</string> - <string name="suggestions">הצעות</string> - <string name="switch_user">החלף</string> - <string name="top_unwatched">עם הדירוג הגבוה ביותר שלא נצפו</string> - <string name="tv_dvr_schedule">תזמון הקלטות</string> - <string name="tv_guide">מדריך</string> - <string name="unknown">לא ידוע</string> - <string name="updates">עדכונים</string> - <string name="version">גרסה</string> - <string name="video_scale">קנה מידה של הוידאו</string> - <string name="video">וידאו</string> - <string name="watch_live">צפה בשידור חי</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="one_click_pause">השהה בלחיצה אחת</string> - <string name="one_click_pause_summary_on">לחץ על מרכז כפתור הניווט להשהייה/ניגון</string> - <string name="italic_font">גופן נטוי</string> - <string name="font">גופן</string> - <string name="background">רקע</string> - <string name="success">הצלחה</string> - <string name="official_rating">דירוג הורים</string> - <string name="runtime_sort">אורך</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="two">שירי־נושא</item> - <item quantity="other">שירי־נושא</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">סרטון־נושא</item> - <item quantity="two">סרטוני־נושא</item> - <item quantity="other">סרטוני־נושא</item> - </plurals> - <plurals name="clips"> - <item quantity="one">קליפ</item> - <item quantity="two">קליפים</item> - <item quantity="other">קליפים</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">סצינה גנוזה</item> - <item quantity="two">סצינות גנוזות</item> - <item quantity="other">סצינות גנוזות</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">ראיון</item> - <item quantity="two">ראיונות</item> - <item quantity="other">ראיונות</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">סצינה</item> - <item quantity="two">סצינות</item> - <item quantity="other">סצינות</item> - </plurals> - <plurals name="samples"> - <item quantity="one">דגימה</item> - <item quantity="two">דגימות</item> - <item quantity="other">דגימות</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">קטע־בונוס</item> - <item quantity="two">קטעי־בונוס</item> - <item quantity="other">קטעי־בונוס</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">קטע קצר</item> - <item quantity="two">קטעים קצרים</item> - <item quantity="other">קטעים קצרים</item> - </plurals> - <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> - <string name="app_theme">ערכת נושא של האפליקציה</string> - <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="combine_continue_next"><![CDATA[מזג המשך צפייה והבא בתור]]></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="global_content_scale">קנה מידה ברירת המחדל של התוכן</string> - <string name="install_update">התקן עדכון</string> - <string name="installed_version">גרסה מותקנת</string> - <string name="max_homepage_items">מספר פריטים מקסימלי בשורות מסך הבית</string> - <string name="nav_drawer_pins_summary">בחר בפריטים ברירת המחדל להצגה, אחרים יוסתרו</string> - <string name="nav_drawer_pins">התאמה אישית של תפריט הניווט</string> - <string name="nav_drawer_switch_on_focus_summary_off">לחץ כדי לעבור בין דפים</string> - <string name="nav_drawer_switch_on_focus">עבור לדף הנבחר בתפריט הניווט אוטומטית</string> - <string name="pass_out_protection">הגנת שינה</string> - <string name="play_theme_music">נגן מוזיקת נושא</string> - <string name="playback_overrides">עקיפת הגדרות ניגון</string> - <string name="rewatch_next_up">אפשר צפייה חוזרת ב־\"הבא בתור\"</string> - <string name="reset">איפוס</string> - <string name="bold_font">גופן מודגש</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> - <string name="exoplayer_options">אפשרויות ExoPlayer</string> - <string name="skip_segment_unknown">דלג על המקטע</string> - <string name="skip_segment_commercial">דלג על הפרסומות</string> - <string name="skip_segment_preview">דלג על התצוגה המקדימה</string> - <string name="skip_segment_recap">דלג על התקציר</string> - <string name="skip_segment_outro">דלג על כתוביות הסיום</string> - <string name="skip_segment_intro">דלג על הפתיח</string> - <string name="played">נוגן</string> - <string name="filter">סנן</string> - <string name="year">שנה</string> - <string name="decade">עשור</string> - <string name="remove">הסר</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="videos">סרטוני וידאו</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZאבגדהוזחטיכלמנסעפצקרשת</string> - <string name="seek_bar_steps">מדרגות דילוג בפס הניגון</string> - <string name="send_app_logs_summary">שימושי לניפוי־שגיאות</string> - <string name="send_app_logs">שלח לוגים של האפליקציה לשרת הנוכחי</string> - <string name="send_crash_reports_summary">ניסיון שליחה לשרת האחרון שחובר יתבצע</string> - <string name="send_crash_reports">שלח דוחות קריסה</string> - <string name="settings">הגדרות</string> - <string name="skip_back_on_resume_preference">חזור אחורה כשממשיכים בניגון</string> - <string name="skip_back_preference">חזור אחורה</string> - <string name="skip_commercials_behavior">התנהגות הדילוג על פרסומות</string> - <string name="skip_forward_preference">דלג קדימה</string> - <string name="skip_intro_behavior">התנהגות הדילוג על פתיחים</string> - <string name="skip_outro_behavior">התנהגות הדילוג על כתוביות הסיום</string> - <string name="skip_previews_behavior">התנהגות הדילוג על תצוגות מקדימות</string> - <string name="skip_recap_behavior">התנהגות הדילוג על תקצירים</string> - <string name="update_available">עדכון זמין</string> - <string name="update_url_summary">כתובת לבדיקה של עדכונים לאפליקציה</string> - <string name="update_url">עדכון כתובת</string> - <string name="font_size">גודל גופן</string> - <string name="font_color">צבע גופן</string> - <string name="font_opacity">אטימות גופן</string> - <string name="edge_style">סגנון קו־מתאר</string> - <string name="edge_color">צבע קו־מתאר</string> - <string name="background_opacity">אטימות רקע</string> - <string name="background_style">סגנון רקע</string> - <string name="subtitle_style">סגנון כתוביות</string> - <string name="background_color">צבע רקע</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="subtitle_margin">שוליים</string> - <string name="verbose_logging">תיעוד לוגים מפורט</string> - <string name="enter_pin">הכנס PIN</string> - <string name="use_server_credentials">היכנס באמצעות השרת</string> - <string name="press_enter_to_confirm">לחץ על האמצע כדי לאשר</string> - <string name="require_pin_code">דרוש PIN לפרופילים</string> - <string name="confirm_pin">אמת PIN</string> - <string name="incorrect">שגוי</string> - <string name="pin_too_short">ה־PIN חייב להיות 4 מקשים לפחות</string> - <string name="will_remove_pin">ה־PIN יימחק</string> - <string name="image_cache_size">גודל מטמון על הדיסק עבור תמונות (MB)</string> - <string name="view_options">אפשרויות תצוגה</string> - <string name="columns">עמודות</string> - <string name="spacing">ריווח</string> - <string name="aspect_ratio">יחס קנה־מידה</string> - <string name="show_details">הצג פרטים</string> - <string name="image_type">סוג תמונה</string> - <string name="cast_and_crew"><![CDATA[שחקנים וצוות]]></string> - <string name="guest_stars">שחקנים אורחים</string> - <string name="edge_size">גודל קצה</string> - <string name="refresh_rate_switching">החלפת קצב רענון־תצוגה</string> - <string name="automatic">אוטומטי</string> - <string name="username_or_password">שימוש בשם משתמש וסיסמה</string> - <string name="general">כללי</string> - <string name="container">מעטפת</string> - <string name="title">כותרת</string> - <string name="codec">מקודד</string> - <string name="profile">פרופיל</string> - <string name="level">רמה</string> - <string name="resolution">רזולוציה</string> - <string name="interlaced">סריקה־שלובה</string> - <string name="framerate">קצב פריימים</string> - <string name="bit_depth">עומק־סיביות</string> - <string name="video_range">טווח וידאו</string> - <string name="video_range_type">סוג טווח וידאו</string> - <string name="color_space">מרחב צבעים</string> - <string name="color_transfer">העברת צבעים</string> - <string name="color_primaries">צבעי יסוד</string> - <string name="pixel_format">פורמט פיקסל</string> - <string name="ref_frames">פריימי־ייחוס</string> - <string name="language">שפה</string> - <string name="layout">פריסה</string> - <string name="channels">ערוצים</string> - <string name="sample_rate">קצב דגימה</string> - <string name="yes">כן</string> - <string name="no">לא</string> - <string name="bit_unit">ביט</string> - <string name="sample_rate_unit">הרץ</string> - <string name="show_titles">הצג כותרות</string> - <string name="favorite_channels_at_beginning">הצג ערוצים מועדפים ראשונים</string> - <string name="sort_channels_recently_watched">מיין ערוצים לפי \"נצפו לאחרונה\"</string> - <string name="subtitle_delay">היסט כתוביות</string> - <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="clear_track_choices">ניקוי בחירת רצועות</string> - <string name="force_dovi_profile_7">ניגון ישיר של Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">התעלם מבדיקות תאימות של המכשיר</string> - <string name="discover">גלה</string> - <string name="request">בקש</string> - <string name="pending">ממתין</string> - <string name="seerr_integration">אינטגרציה עם Seerr</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">כתובת</string> - <string name="trending">פופולרי עכשיו</string> - <string name="upcoming_movies">סרטים שיוצאים בקרוב</string> - <string name="upcoming_tv">סדרות שיוצאות בקרוב</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="hdr_subtitle_style">סגנון כתוביות HDR</string> - <string name="brightness">בהירות</string> - <string name="contrast">ניגודיות</string> - <string name="saturation">רוויה</string> - <string name="hue">גוון</string> - <string name="red">אדום</string> - <string name="green">ירוק</string> - <string name="blue">כחול</string> - <string name="blur">טשטוש</string> - <string name="save_for_album">שמירה לאלבום</string> - <string name="play_slideshow">הפעלת מצגת</string> - <string name="stop_slideshow">עצירת מצגת</string> - <string name="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="max_days_next_up">מספר ימים מקסימלי ב־\"הבא בתור\"</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="add_row">הוסף שורה</string> - <string name="genres_in">ז\'אנרים ב־%1$s</string> - <string name="recently_released_in">יצא לאחרונה ב־%1$s</string> - <string name="height">גובה</string> - <string name="apply_all_rows">החל על כל השורות</string> - <string name="customize_home">התאמה אישית של מסך הבית</string> - <string name="home_rows">שורות במסך הבית</string> - <string name="load_from_server">טען מפרופיל המשתמש בשרת</string> - <string name="save_to_server">שמירה לפרופיל המשתמש בשרת</string> - <string name="load_from_web_client">טען מלקוח הדפדפן</string> - <string name="use_series">השתמש בתמונה של הסדרה</string> - <string name="add_row_for">הוסף שורה ל־%1$s</string> - <string name="overwrite_server_settings">דריסת הגדרות בשרת?</string> - <string name="overwrite_local_settings">דריסת הגדרות מקומיות?</string> - <string name="for_episodes">עבור פרקים</string> - <string name="suggestions_for">הצעות עבור %1$s</string> - <string name="increase_all_cards_size">הגדלה של כל הכרטיסיות</string> - <string name="decrease_all_cards_size">הקטנה של כל הכרטיסיות</string> - <string name="use_thumb_images">השתמש בתמונות־ממוזערות</string> - <string name="settings_saved">ההגדרות נשמרו</string> - <string name="display_presets">תבניות תצוגה</string> - <string name="display_presets_description">תבניות מוטמעות לעיצוב מהיר של כל השורות</string> - <string name="customize_home_summary">בחירת שורות ותמונות במסך הבית</string> - <string name="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">ברירת המחדל של Wolfskin</string> - <string name="display_preset_compact">Wolfskin קומפקטי</string> - <string name="display_preset_series_thumb">תמונות־ממוזערות של סדרה</string> - <string name="display_preset_episode_thumbnails">תמונות־ממוזערות של פרק</string> - <string name="bold_blue">כחול עז</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="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="backdrop_style_dynamic">תמונה עם צבע דינאמי</string> - <string name="backdrop_style_image">תמונה בלבד</string> - <string name="enter_url_api_key"><![CDATA[הכנס כתובת ומפתח 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">Wolfskin עודכן ל־%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="nal">NAL</string> - <string name="avc">AVC</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="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="anamorphic">אנאמורפי</string> - <string name="image_subtitle_opacity">אטימות כתוביות מבוססות־תמונה</string> - <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="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> - <string name="prefer_dovi_over_hdr10_plus">העדפת Dolby Vision על פני HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה</string> - <string name="remove_from_playlist">מחיקה מרשימת־השמעה</string> - <string name="login_credentials_expired">פרטי ההתחברות פגו, נא להתחבר מחדש</string> - <string name="login_not_authorized">אינך מורשה לגשת לשרת בשלב זה</string> - <string name="max_resolution">רזולוציה מירבית לפני המרת־קידוד</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">פרק %1$d</item> - <item quantity="two">%1$d פרקים</item> - <item quantity="other">%1$d פרקים</item> - </plurals> -</resources> diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml deleted file mode 100644 index 7b8e9032..00000000 --- a/app/src/main/res/values-kab/strings.xml +++ /dev/null @@ -1,56 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Ɣef</string> - <string name="audio">Imesli</string> - <string name="birthplace">Adig n tlalit</string> - <string name="bitrate">Aktum</string> - <string name="cancel">Semmet</string> - <string name="chapters">Ixfawen</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> - <string name="died">Yemmut</string> - <string name="hide">Ffer-it</string> - <string name="home">Tazwara</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Tamkarḍit</string> - <string name="restart">Ales asekker</string> - <string name="resume">Kemmel</string> - <string name="save">Sekles</string> - <string name="search">Nadi</string> - <string name="searching">Yettnadi…</string> - <string name="show">Sken-d</string> - <string name="sort_by_name">Isem</string> - <string name="sort_by_random">Agacuṛan</string> - <string name="version">Lqem</string> - <string name="video">Tavidyut</string> - <string name="videos">Tividyutin</string> - <string name="font">Tasefsit</string> - <string name="background">Agilal</string> - <string name="success">Yedda</string> - <string name="year">Aseggas</string> - <string name="automatic">Awurman</string> - <string name="login">Tuqqna</string> - <string name="general">Amatu</string> - <string name="container">Amagbar</string> - <string name="title">Azwel</string> - <string name="codec">Akudak</string> - <string name="profile">Amaɣnu</string> - <string name="level">Aswir</string> - <string name="hdr10">HDR10</string> - <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-ko/strings.xml b/app/src/main/res/values-ko/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-ko/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> -</resources> diff --git a/app/src/main/res/values-lo/strings.xml b/app/src/main/res/values-lo/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-lo/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?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 deleted file mode 100644 index 50efdb0c..00000000 --- a/app/src/main/res/values-lv/strings.xml +++ /dev/null @@ -1,311 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Par</string> - <string name="active_recordings">Aktīvie ieraksti</string> - <string name="add_favorite">Izlase</string> - <string name="add_server">Pievienot Serveri</string> - <string name="add_user">Pievienot Lietotāju</string> - <string name="audio">Audio celiņš</string> - <string name="birthplace">Dzimšanas vieta</string> - <string name="bitrate">Bitu ātrums</string> - <string name="born">Dzimis</string> - <string name="cancel_recording">Atcelt Ierakstu</string> - <string name="cancel_series_recording">Atcelt Seriālu Ierakstu</string> - <string name="cancel">Atcelt</string> - <string name="chapters">Nodaļas</string> - <string name="choose_stream">Izvēlēties %1$s</string> - <string name="clear_image_cache">Dzēst attēlu kešatmiņu</string> - <string name="collection">Kolekcija</string> - <string name="collections">Kolekcijas</string> - <string name="commercial">Reklāma</string> - <string name="community_rating">Skatītāju vērtējums</string> - <string name="compose_error_message_title">Radās kļūda! Nospiediet pogu, lai sūtītu žurnālus uz savu serveri.</string> - <string name="confirm">Apstiprināt</string> - <string name="continue_watching">Turpināt skatīties</string> - <string name="critic_rating">Kritiķu vērtējums</string> - <string name="decimal_seconds">%.2f sekundes</string> - <string name="default_track">Noklusējums</string> - <string name="delete">Dzēst</string> - <string name="died">Miris</string> - <string name="directed_by">Režisējis %1$s</string> - <string name="director">Režisors</string> - <string name="disabled">Atslēgts</string> - <string name="discovered_servers">Atrastie serveri</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> - <string name="enter_server_url">Ievadi Servera IP vai URL</string> - <string name="enter_server_address">Ievadi servera adresi</string> - <string name="episodes">Sērijas</string> - <string name="error_loading_collection">Kļūda, ielādējot kolekciju %1$s</string> - <string name="external_track">Ārējais</string> - <string name="favorites">Izlases</string> - <string name="file_size">Izmērs</string> - <string name="forced_track">Piespiedu</string> - <string name="genres">Žanri</string> - <string name="go_to_series">Iet uz sērijām</string> - <string name="go_to">Iet Uz</string> - <string name="hide_controller_timeout">Paslēpt atskaņošanas kontroles</string> - <string name="hide_debug_info">Paslēpt atkļūdošanas info</string> - <string name="hide">Paslēpt</string> - <string name="home">Sākums</string> - <string name="immediate">Tūlītēja</string> - <string name="intro">Ievads</string> - <string name="library">Bibliotēka</string> - <string name="license_info">Licences informācija</string> - <string name="live_tv">Tiešraides</string> - <string name="loading">Ielādē…</string> - <string name="mark_entire_series_as_played">Atzīmēt visas sērijas kā redzētas?</string> - <string name="mark_entire_series_as_unplayed">Atzīmēt visas sērijas kā neredzētas?</string> - <string name="mark_unwatched">Atzīmēt kā neredzētu</string> - <string name="mark_watched">Atzīmēt kā redzētu</string> - <string name="max_bitrate">Maksimālais bitu ātrums</string> - <string name="more_like_this">Līdzīgs saturs</string> - <string name="more">Vairāk</string> - <plurals name="movies"> - <item quantity="zero">Filmas</item> - <item quantity="other">Filmas</item> - </plurals> - <string name="name">Nosaukums</string> - <string name="next_up">Rindā</string> - <string name="no_data">Nav datu</string> - <string name="no_results">Nav rezultātu</string> - <string name="no_servers_found">Serveri netika atrasti</string> - <string name="no_scheduled_recordings">Nav ieplānoti ieraksti</string> - <string name="no_update_available">Nav pieejami atjauninājumi</string> - <string name="none">Neviens</string> - <string name="only_forced_subtitles">Tikai Piespiedu Subtitri</string> - <string name="outro">Nobeigums</string> - <string name="path">Ceļš</string> - <plurals name="people"> - <item quantity="zero">Cilvēki</item> - <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> - <string name="play">Atskaņot</string> - <string name="playback_debug_info">Rādīt atkļūdošanas informāciju</string> - <string name="playback_speed">Atskaņošanas ātrums</string> - <string name="playback">Atskaņošana</string> - <string name="playlist">Saraksts</string> - <string name="playlists">Saraksti</string> - <string name="preview">Priekšskatījums</string> - <string name="profile_specific_settings">Lietotāja Profila Iestatījumi</string> - <string name="queue">Rinda</string> - <string name="recap">Atskats</string> - <string name="recently_added_in">Nesen pievienots sadaļā %1$s</string> - <string name="recently_added">Nesen pievienots</string> - <string name="recently_recorded">Nesenie Ieraksti</string> - <string name="recently_released">Nesen Iznācis</string> - <string name="recommended">Ieteicamie</string> - <string name="record_program">Ierakstīt Raidījumu</string> - <string name="record_series">Ierakstīt Sērijas</string> - <string name="remove_favorite">Noņemt no izlases</string> - <string name="restart">Restartēt</string> - <string name="resume">Turpināt</string> - <string name="save">Saglabā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> - <string name="voice_starting">Uzsāk</string> - <string name="voice_search_prompt">Runā lai meklētu</string> - <string name="processing">Apstrādā</string> - <string name="press_back_to_cancel">Spied atpakaļ lai atceltu</string> - <string name="voice_error_audio">Audio ieraksta kļūda</string> - <string name="voice_error_client">Balss atpazīšanas kļūda</string> - <string name="voice_error_permissions">Mikrofona tiesību kļūda</string> - <string name="voice_error_network">Tīkla kļūda</string> - <string name="voice_error_network_timeout">Tīkla noilgums</string> - <string name="voice_error_no_match">Balss netika atpazīta</string> - <string name="voice_error_busy">Balss atpazīšana aizņemta</string> - <string name="voice_error_server">Servera kļūda</string> - <string name="voice_error_speech_timeout">Neviena balss netika atklāta</string> - <string name="voice_error_unknown">Nezināma kļūda</string> - <string name="voice_error_start_failed">Kļūda sākot balss atpazīšanu</string> - <string name="voice_error_timeout">Balss atpazīšanai iestājies noilgums</string> - <string name="retry">Mēģināt vēlreiz</string> - <string name="select_server">Izvēlies Serveri</string> - <string name="select_user">Izvēlies Lietotāju</string> - <string name="show_debug_info">Rādīt atkļūdošanas info</string> - <string name="show_next_up_when">Rādīt \"Skaties tālāk\"</string> - <string name="show">Rādīt</string> - <string name="shuffle">Sajaukt</string> - <string name="skip">Izlaist</string> - <string name="sort_by_date_added">Pievienošanas Datums</string> - <string name="sort_by_date_episode_added">Sērijas pievienošanas datums</string> - <string name="sort_by_date_played">Atskaņošanas Datums</string> - <string name="sort_by_date_released">Izdošanas Datums</string> - <string name="sort_by_name">Nosaukums</string> - <string name="sort_by_random">Nejaušs</string> - <string name="studios">Studija</string> - <string name="submit">Iesniegt</string> - <string name="subtitle_download_too_long">Lejupielāde ir lēna, iespējams jāmēģina restartēt video</string> - <string name="subtitle">Subtitri</string> - <string name="subtitles">Subtitri</string> - <string name="suggestions">Ieteikumi</string> - <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> - <plurals name="trailers"> - <item quantity="zero">Treileri</item> - <item quantity="other">Treileri</item> - </plurals> - <string name="tv_dvr_schedule">Ierakstu plāns</string> - <string name="tv_guide">Programma</string> - <string name="tv_season">Sezona</string> - <string name="tv_seasons">Sezonas</string> - <plurals name="tv_shows"> - <item quantity="zero">TV Šovi</item> - <item quantity="other">TV Šovi</item> - </plurals> - <string name="ui_interface">Saskarne</string> - <string name="unknown">Nezināms</string> - <string name="updates">Atjauninājumi</string> - <string name="version">Versija</string> - <string name="video_scale">Video Mērogs</string> - <string name="video">Video</string> - <string name="videos">Video</string> - <string name="watch_live">Skatīties tiešraidi</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> - <string name="aired_episode_order">Iznākušo Sēriju Secība</string> - <string name="create_playlist">Izveidot jaunu izlasi</string> - <string name="add_to_playlist">Pievienot izlasei</string> - <string name="one_click_pause">Apturēt ar vienu klikšķi</string> - <string name="one_click_pause_summary_on">Nospiediet pults vidu, lai pauzētu/atskaņotu</string> - <string name="italic_font">Slīpraksts</string> - <string name="font">Teksts</string> - <string name="background">Fons</string> - <string name="success">Veiksmīgi</string> - <string name="official_rating">Vecuma ierobežojums</string> - <string name="runtime_sort">Ilgums</string> - <string name="extras_title">Ekstras</string> - <plurals name="other_extras"> - <item quantity="zero">Cits</item> - <item quantity="other">Cits</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="zero">Aizkadri</item> - <item quantity="other">Aizkadri</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="zero">Tituldziesmas</item> - <item quantity="other">Tituldziesmas</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="zero">Titulvideo</item> - <item quantity="other">Titulvideo</item> - </plurals> - <plurals name="clips"> - <item quantity="zero">Klipi</item> - <item quantity="other">Klipi</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="zero">Izgrieztās ainas</item> - <item quantity="other">Izgrieztās ainas</item> - </plurals> - <plurals name="interviews"> - <item quantity="zero">Intervijas</item> - <item quantity="other">Intervijas</item> - </plurals> - <plurals name="scenes"> - <item quantity="zero">Ainas</item> - <item quantity="other">Ainas</item> - </plurals> - <plurals name="samples"> - <item quantity="zero">Paraugi</item> - <item quantity="other">Paraugi</item> - </plurals> - <plurals name="featurettes"> - <item quantity="zero">Īssižeti</item> - <item quantity="other">Īssižeti</item> - </plurals> - <plurals name="shorts"> - <item quantity="zero">Īsie rullīši</item> - <item quantity="other">Īsie rullīši</item> - </plurals> - <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> - <item quantity="other">%s lejupielādes</item> - </plurals> - <plurals name="hours"> - <item quantity="zero">%d stundas</item> - <item quantity="one">%d stunda</item> - <item quantity="other">%d stundas</item> - </plurals> - <plurals name="days"> - <item quantity="zero">%s dienas</item> - <item quantity="one">%s diena</item> - <item quantity="other">%s dienas</item> - </plurals> - <plurals name="items"> - <item quantity="zero">%d elementi</item> - <item quantity="one">%d elements</item> - <item quantity="other">%d elementi</item> - </plurals> - <plurals name="seconds"> - <item quantity="zero">%d sekundes</item> - <item quantity="one">%d sekunde</item> - <item quantity="other">%d sekundes</item> - </plurals> - <plurals name="minutes"> - <item quantity="zero">%s minūtes</item> - <item quantity="one">%s minūte</item> - <item quantity="other">%s minūtes</item> - </plurals> - <string name="settings">Iestatījumi</string> - <string name="reset">Atiestatīt</string> - <string name="played">Atskaņots</string> - <string name="saturation">Piesātinājums</string> - <string name="hue">Tonis</string> - <string name="red">Sarkans</string> - <string name="green">Zaļš</string> - <string name="blue">Zils</string> - <string name="albums">Albumi</string> - <string name="songs">Dziesmas</string> - <string name="height">Augstums</string> - <string name="screensaver">Ekrānsaudzētājs</string> - <string name="duration">Ilgums</string> - <string name="year">Gads</string> - <string name="decade">Desmitgade</string> - <string name="remove">Noņemt</string> - <string name="columns">Kolonnas</string> - <string name="spacing">Atstarpe</string> - <string name="automatic">Automātiski</string> - <string name="container">Konteiners</string> - <string name="title">Nosaukums</string> - <string name="codec">Kodeks</string> - <string name="profile">Profils</string> - <string name="level">Līmenis</string> - <string name="resolution">Izšķirtspēja</string> - <string name="framerate">Kadru ātrums/skaits</string> - <string name="language">Valoda</string> - <string name="channels">Kanāli</string> - <string name="avc">AVC</string> - <string name="yes">Jā</string> - <string name="no">Nē</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">bits</string> - <string name="sample_rate_unit">Hz</string> - <string name="dts_x">DTS:X</string> - <string name="dts_hd">DTS:HD</string> - <string name="truehd">TrueHD</string> - <string name="dolby_digital">DD</string> - <string name="dolby_digital_plus">DD+</string> - <string name="dts">DTS</string> - <string name="discover">Atklāt</string> - <string name="password">Parole</string> - <string name="username">Lietotājvārds</string> -</resources> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index c8066cec..0583052c 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -1,712 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="about">Om</string> - <string name="active_recordings">Aktive opptak</string> - <string name="add_favorite">Legg til favoritt</string> - <string name="add_server">Legg til server</string> - <string name="add_user">Legg til bruker</string> - <string name="audio">Lyd</string> - <string name="birthplace">Fødested</string> - <string name="bitrate">Bitrate</string> - <string name="born">Født</string> - <string name="cancel_recording">Avbryt opptak</string> - <string name="cancel_series_recording">Avbryt serieopptak</string> - <string name="cancel">Avbryt</string> - <string name="chapters">Kapitler</string> - <string name="choose_stream">Velg %1$s</string> - <string name="clear_image_cache">Tøm bildebuffer</string> - <string name="collection">Samling</string> - <string name="collections">Samlinger</string> - <string name="commercial">Reklame</string> - <string name="community_rating">Brukervurdering</string> - <string name="compose_error_message_title">En feil har oppstått! Trykk på knappen for å sende logger til serveren din.</string> - <string name="confirm">Bekreft</string> - <string name="continue_watching">Fortsett å se</string> - <string name="critic_rating">Kritikervurdering</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">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> - <string name="enter_server_address">Oppgi serveradresse</string> - <string name="episodes">Episoder</string> - <string name="error_loading_collection">Feil ved lasting av samling %1$s</string> - <string name="external_track">Ekstern</string> - <string name="favorites">Favoritter</string> - <string name="file_size">Størrelse</string> - <string name="forced_track">Tvungen</string> - <string name="genres">Sjangere</string> - <string name="go_to_series">Gå til serie</string> - <string name="go_to">Gå til</string> - <string name="hide_controller_timeout">Skjul avspillingskontroller</string> - <string name="hide_debug_info">Skjul feilsøkingsinfo</string> - <string name="hide">Skjul</string> - <string name="home">Hjem</string> - <string name="immediate">Umiddelbart</string> - <string name="intro">Intro</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string> - <string name="library">Bibliotek</string> - <string name="license_info">Lisensinformasjon</string> - <string name="live_tv">Direkte-TV</string> - <string name="loading">Laster…</string> - <string name="mark_entire_series_as_played">Marker hele serien som sett?</string> - <string name="mark_entire_series_as_unplayed">Marker hele serien som usett?</string> - <string name="mark_unwatched">Marker som usett</string> - <string name="mark_watched">Marker som sett</string> - <string name="max_bitrate">Maks bitrate</string> - <string name="more_like_this">Mer som dette</string> - <string name="more">Mer</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> - <string name="no_results">Ingen resultater</string> - <string name="no_servers_found">Ingen servere funnet</string> - <string name="no_scheduled_recordings">Ingen planlagte opptak</string> - <string name="no_update_available">Ingen oppdatering tilgjengelig</string> - <string name="none">Ingen</string> - <string name="outro">Outro</string> - <string name="path">Filbane</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> - <string name="playback_debug_info">Vis feilsøkingsinfo for avspilling</string> - <string name="playback_speed">Avspillingshastighet</string> - <string name="playback">Avspilling</string> - <string name="playlist">Spilleliste</string> - <string name="playlists">Spillelister</string> - <string name="preview">Forhåndsvisning</string> - <string name="profile_specific_settings">Innstillinger for brukerprofil</string> - <string name="queue">Kø</string> - <string name="recap">Sammendrag</string> - <string name="recently_added_in">Sist lagt til i %1$s</string> - <string name="recently_added">Sist lagt til</string> - <string name="recently_recorded">Sist tatt opp</string> - <string name="recently_released">Sist utgitt</string> - <string name="recommended">Anbefalt</string> - <string name="record_program">Ta opp program</string> - <string name="record_series">Ta opp serie</string> - <string name="remove_favorite">Fjern favoritt</string> - <string name="restart">Start på nytt</string> - <string name="resume">Fortsett</string> - <string name="save">Lagre</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> - <string name="select_user">Velg bruker</string> - <string name="show_debug_info">Vis feilsøkingsinfo</string> - <string name="show_next_up_when">Vis \'Neste\'</string> - <string name="show">Serie</string> - <string name="shuffle">Tilfeldig rekkefølge</string> - <string name="skip">Hopp over</string> - <string name="sort_by_date_added">Dato lagt til</string> - <string name="sort_by_date_episode_added">Dato episode lagt til</string> - <string name="sort_by_date_played">Dato avspilt</string> - <string name="sort_by_date_released">Utgivelsesdato</string> - <string name="sort_by_name">Navn</string> - <string name="sort_by_random">Tilfeldig</string> - <string name="studios">Studioer</string> - <string name="submit">Send inn</string> - <string name="subtitle_download_too_long">Nedlastingen tar lang tid, du må kanskje starte avspillingen på nytt</string> - <string name="subtitle">Undertekst</string> - <string name="subtitles">Undertekster</string> - <string name="suggestions">Forslag</string> - <string name="switch_servers">Bytt server</string> - <string name="switch_user">Bytt bruker</string> - <string name="top_unwatched">Best vurderte usette</string> - <plurals name="trailers"> - <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_title">TV-serier</string> - <string name="ui_interface">Grensesnitt</string> - <string name="unknown">Ukjent</string> - <string name="updates">Oppdateringer</string> - <string name="version">Versjon</string> - <string name="video_scale">Videoskalering</string> - <string name="video">Video</string> - <string name="videos">Videoer</string> - <string name="watch_live">Se direkte</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> - <string name="aired_episode_order">Rekkefølge etter sendedato</string> - <string name="create_playlist">Lag ny spilleliste</string> - <string name="add_to_playlist">Legg til i spilleliste</string> - <string name="one_click_pause">Pause med ett klikk</string> - <string name="one_click_pause_summary_on">Trykk i midten på styreputen for å pause/spille av</string> - <string name="italic_font">Kursiv skrift</string> - <string name="font">Skrifttype</string> - <string name="background">Bakgrunn</string> - <string name="success">Fullført</string> - <string name="official_rating">Aldersgrense</string> - <string name="runtime_sort">Spilletid</string> - <string name="extras_title">Ekstramateriale</string> - <plurals name="other_extras"> - <item quantity="one">Annet</item> - <item quantity="other">Annet</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Bak kulissene</item> - <item quantity="other">Bak kulissene</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Kjenningsmelodi</item> - <item quantity="other">Kjenningsmelodier</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Kjenningsvideo</item> - <item quantity="other">Kjenningsvideoer</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klipp</item> - <item quantity="other">Klipp</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Slettet scene</item> - <item quantity="other">Slettede scener</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Intervju</item> - <item quantity="other">Intervjuer</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Scene</item> - <item quantity="other">Scener</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Smakebit</item> - <item quantity="other">Smakebiter</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Novellefilm</item> - <item quantity="other">Novellefilmer</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Kortfilm</item> - <item quantity="other">Kortfilmer</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s nedlasting</item> - <item quantity="other">%s nedlastinger</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d time</item> - <item quantity="other">%d timer</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d element</item> - <item quantity="other">%d elementer</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekund</item> - <item quantity="other">%d sekunder</item> - </plurals> - <string name="advanced_settings">Avanserte innstillinger</string> - <string name="advanced_ui">Avansert grensesnitt</string> - <string name="app_theme">App-tema</string> - <string name="auto_check_for_updates">Sjekk automatisk etter oppdateringer</string> - <string name="auto_play_next_delay">Forsinkelse før neste spilles av</string> - <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">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> - <string name="global_content_scale">Standard innholdsskalering</string> - <string name="install_update">Installer oppdatering</string> - <string name="installed_version">Installert versjon</string> - <string name="max_homepage_items">Maks elementer på rader på hjemskjermen</string> - <string name="nav_drawer_pins_summary">Velg standardelementer som skal vises; andre skjules</string> - <string name="nav_drawer_pins">Tilpass elementer i navigasjonsskuffen</string> - <string name="nav_drawer_switch_on_focus_summary_off">Klikk for å bytte side</string> - <string name="nav_drawer_switch_on_focus">Bytt side i navigasjonsskuffen ved fokus</string> - <string name="pass_out_protection">Søvnbeskyttelse</string> - <string name="play_theme_music">Spill temamusikk</string> - <string name="playback_overrides">Overstyring av avspilling</string> - <string name="remember_selected_tab">Husk valgte faner</string> - <string name="rewatch_next_up">Tillat gjensyn i \'Neste\'</string> - <string name="seek_bar_steps">Steglengde for søkelinje</string> - <string name="send_app_logs_summary">Nyttig for feilsøking</string> - <string name="send_app_logs">Send app-logger til gjeldende server</string> - <string name="send_crash_reports_summary">Vil prøve å sende til sist tilkoblede server</string> - <string name="send_crash_reports">Send krasjrapporter</string> - <string name="settings">Innstillinger</string> - <string name="skip_back_on_resume_preference">Hopp tilbake ved gjenopptakelse av avspilling</string> - <string name="skip_back_preference">Hopp tilbake</string> - <string name="skip_commercials_behavior">Handling for hopping over reklame</string> - <string name="skip_forward_preference">Hopp fremover</string> - <string name="skip_intro_behavior">Handling for hopping over intro</string> - <string name="skip_outro_behavior">Handling for hopping over outro</string> - <string name="skip_previews_behavior">Handling for hopping over forhåndsvisning</string> - <string name="skip_recap_behavior">Handling for hopping over sammendrag</string> - <string name="update_available">Oppdatering tilgjengelig</string> - <string name="update_url_summary">URL for sjekk av app-oppdateringer</string> - <string name="update_url">URL for oppdatering</string> - <string name="font_size">Skriftstørrelse</string> - <string name="font_color">Skriftfarge</string> - <string name="font_opacity">Gjennomsiktighet for skrift</string> - <string name="edge_style">Kantstil</string> - <string name="edge_color">Kantfarge</string> - <string name="background_opacity">Gjennomsiktighet for bakgrunn</string> - <string name="background_style">Bakgrunnsstil</string> - <string name="subtitle_style">Stil for undertekst</string> - <string name="background_color">Bakgrunnsfarge</string> - <string name="reset">Nullstill</string> - <string name="bold_font">Fet skrift</string> - <string name="player_backend">Avspillingsmotor</string> - <string name="mpv_hardware_decoding">MPV: Bruk maskinvaredekoding</string> - <string name="disable_if_crash">Deaktiver dersom appen krasjer</string> - <string name="mpv_options">MPV-valg</string> - <string name="exoplayer_options">ExoPlayer-valg</string> - <string name="skip_segment_unknown">Hopp over segment</string> - <string name="skip_segment_commercial">Hopp over reklame</string> - <string name="skip_segment_preview">Hopp over forhåndsvisning</string> - <string name="skip_segment_recap">Hopp over sammendrag</string> - <string name="skip_segment_outro">Hopp over outro</string> - <string name="skip_segment_intro">Hopp over intro</string> - <string name="played">Sett</string> - <string name="filter">Filter</string> - <string name="year">År</string> - <string name="decade">Tiår</string> - <string name="remove">Fjern</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Bruk gpu-next</string> - <string name="mpv_conf">Rediger mpv.conf</string> - <string name="discard_change">Forkast endringer?</string> - <string name="subtitle_margin">Margin</string> - <string name="verbose_logging">Detaljert logging</string> - <string name="enter_pin">Tast inn PIN</string> - <string name="sign_in_auto">Logg inn automatisk</string> - <string name="use_server_credentials">Logg inn via server</string> - <string name="press_enter_to_confirm">Trykk i midten for å bekrefte</string> - <string name="require_pin_code">Krev PIN for profil</string> - <string name="confirm_pin">Bekreft PIN</string> - <string name="incorrect">Feil</string> - <string name="pin_too_short">PIN må være 4 taster eller lenger</string> - <string name="will_remove_pin">Vil fjerne PIN</string> - <string name="image_cache_size">Størrelse på bildebuffer (MB)</string> - <string name="view_options">Visningsvalg</string> - <string name="columns">Kolonner</string> - <string name="spacing">Mellomrom</string> - <string name="aspect_ratio">Bildeforhold</string> - <string name="show_details">Vis detaljer</string> - <string name="image_type">Bildetype</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> - <string name="automatic">Automatisk</string> - <string name="username_or_password">Bruk brukernavn/passord</string> - <string name="login">Logg inn</string> - <string name="general">Generelt</string> - <string name="container">Container</string> - <string name="title">Tittel</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Nivå</string> - <string name="resolution">Oppløsning</string> - <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Linjeflettet</string> - <string name="framerate">Bildefrekvens</string> - <string name="bit_depth">Bit-dybde</string> - <string name="video_range">Videoområde</string> - <string name="video_range_type">Type videoområde</string> - <string name="color_space">Fargerom</string> - <string name="color_transfer">Fargeoverføring</string> - <string name="color_primaries">Fargeprimærer</string> - <string name="pixel_format">Pikselformat</string> - <string name="ref_frames">Referanserammer</string> - <string name="nal">NAL</string> - <string name="language">Språk</string> - <string name="layout">Layout</string> - <string name="channels">Kanaler</string> - <string name="sample_rate">Samplingsfrekvens</string> - <string name="avc">AVC</string> - <string name="yes">Ja</string> - <string name="no">Nei</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Vis titler</string> - <string name="live_tv_repeat">Repeter</string> - <string name="favorite_channels_at_beginning">Vis favorittkanaler først</string> - <string name="sort_channels_recently_watched">Sorter kanaler etter sist sett</string> - <string name="color_code_programs">Fargekod programmer</string> - <string name="subtitle_delay">Undertekstforsinkelse</string> - <string name="backdrop_display">Bakgrunnsstil</string> - <string name="default_track">Standard</string> - <string name="delete">Slette</string> - <string name="died">Død</string> - <string name="directed_by">Regissert av %1$s</string> - <string name="ends_at">Slutter kl. %1$s</string> - <string name="ac3_supported">Enheten støtter AC3/Dolby Digital</string> - <string name="resolution_switching">Bytting av oppløsning</string> - <string name="local">Lokal</string> - <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">Wolfskin Standard</string> - <string name="display_preset_compact">Wolfskin 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">Wolfskin 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 deleted file mode 100644 index 1e4891ba..00000000 --- a/app/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,665 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Over</string> - <string name="active_recordings">Actieve opnames</string> - <string name="add_favorite">Aan favorieten toevoegen</string> - <string name="add_server">Server toevoegen</string> - <string name="add_user">Gebruiker toevoegen</string> - <string name="audio">Audio</string> - <string name="birthplace">Geboorteplaats</string> - <string name="bitrate">Bitsnelheid</string> - <string name="born">Geboortedatum</string> - <string name="cancel_recording">Opname afbreken</string> - <string name="cancel_series_recording">Serieopname afbreken</string> - <string name="cancel">Annuleren</string> - <string name="chapters">Hoofdstukken</string> - <string name="choose_stream">%1$s kiezen</string> - <string name="clear_image_cache">Afbeeldingscache wissen</string> - <string name="collection">Collectie</string> - <string name="collections">Collecties</string> - <string name="commercial">Reclame</string> - <string name="community_rating">Beoordeling (publiek)</string> - <string name="compose_error_message_title">Er is een fout opgetreden. Druk op de knop om de logboeken naar je server te versturen.</string> - <string name="confirm">Bevestigen</string> - <string name="continue_watching">Verder kijken</string> - <string name="critic_rating">Beoordeling (recensie)</string> - <string name="decimal_seconds">%.2f seconden</string> - <string name="default_track">Standaard</string> - <string name="delete">Verwijderen</string> - <string name="died">Overleden</string> - <string name="directed_by">Geregisseerd door %1$s</string> - <string name="director">Regisseur</string> - <string name="disabled">Uitgeschakeld</string> - <string name="discovered_servers">Gevonden servers</string> - <string name="download_and_update"><![CDATA[Downloaden en bijwerken]]></string> - <string name="downloading">Bezig met downloaden…</string> - <string name="enabled">Ingeschakeld</string> - <string name="enter_server_url">Voer IP-adres of url van server in</string> - <string name="episodes">Afleveringen</string> - <string name="error_loading_collection">Fout bij laden %1$s collectie</string> - <string name="external_track">Extern</string> - <string name="favorites">Favorieten</string> - <string name="file_size">Grootte</string> - <string name="forced_track">Geforceerd</string> - <string name="genres">Genres</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> - <string name="home">Home</string> - <string name="immediate">Onmiddellijk</string> - <string name="intro">Intro</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Bibliotheek</string> - <string name="license_info">Licentie-informatie</string> - <string name="live_tv">Live-tv</string> - <string name="loading">Bezig met laden…</string> - <string name="mark_entire_series_as_played">Serie als bekeken markeren?</string> - <string name="mark_entire_series_as_unplayed">Serie als niet-bekeken markeren?</string> - <string name="mark_unwatched">Als niet-bekeken markeren</string> - <string name="mark_watched">Als bekeken markeren</string> - <string name="max_bitrate">Max. bitsnelheid</string> - <string name="more_like_this">Soortgelijk</string> - <string name="more">Meer</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> - <string name="no_results">Geen zoekresultaten</string> - <string name="no_scheduled_recordings">Geen ingeplande opnames</string> - <string name="no_update_available">Geen update beschikbaar</string> - <string name="none">Geen</string> - <string name="outro">Aftiteling</string> - <string name="path">Locatie</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> - <string name="playback_debug_info">Foutopsporingsinformatie tonen</string> - <string name="playback_speed">Afspeelsnelheid</string> - <string name="playback">Afspelen</string> - <string name="playlist">Afspeellijst</string> - <string name="playlists">Afspeellijsten</string> - <string name="preview">Voorvertoning</string> - <string name="profile_specific_settings">Profielinstellingen</string> - <string name="queue">Wachtrij</string> - <string name="recap">Samenvatting</string> - <string name="recently_added_in">Onlangs toegevoegd aan %1$s</string> - <string name="recently_added">Onlangs toegevoegd</string> - <string name="recently_recorded">Onlangs opgenomen</string> - <string name="recently_released">Onlangs verschenen</string> - <string name="recommended">Aanbevolen</string> - <string name="record_program">Programma opnemen</string> - <string name="record_series">Serie opnemen</string> - <string name="remove_favorite">Uit favorieten verwijderen</string> - <string name="restart">Herstarten</string> - <string name="resume">Hervatten</string> - <string name="save">Opslaan</string> - <string name="search_and_download"><![CDATA[Zoeken en downloaden]]></string> - <string name="search">Zoeken</string> - <string name="searching">Aan het zoeken…</string> - <string name="select_server">Kies een server</string> - <string name="select_user">Kies een gebruiker</string> - <string name="show_debug_info">Foutopsporingsinformatie tonen</string> - <string name="show_next_up_when">Wachtrij tonen</string> - <string name="show">Tonen</string> - <string name="shuffle">Willekeurig</string> - <string name="skip">Overslaan</string> - <string name="sort_by_date_added">Toegevoegd</string> - <string name="sort_by_date_episode_added">Toegevoegd (aflevering)</string> - <string name="sort_by_date_played">Bekeken</string> - <string name="sort_by_date_released">Verschenen</string> - <string name="sort_by_name">Naam</string> - <string name="sort_by_random">Willekeurig</string> - <string name="studios">Studio\'s</string> - <string name="submit">Versturen</string> - <string name="subtitle_download_too_long">Het downloaden duurt erg lang - het kan zijn dat je opnieuw moet beginnen met afspelen</string> - <string name="subtitle">Onderschrift</string> - <string name="subtitles">Ondertiteling</string> - <string name="suggestions">Suggesties</string> - <string name="switch_servers">Andere server kiezen</string> - <string name="switch_user">Kiezen</string> - <string name="top_unwatched">Best beoordeeld niet-bekeken</string> - <plurals name="trailers"> - <item quantity="one">Trailer</item> - <item quantity="other">Trailers</item> - </plurals> - <string name="tv_dvr_schedule">DVR-schema</string> - <string name="tv_guide">Gids</string> - <string name="tv_season">Seizoen</string> - <string name="tv_seasons">Seizoenen</string> - <string name="tv_shows_title">Series</string> - <string name="ui_interface">Vormgeving</string> - <string name="unknown">Onbekend</string> - <string name="updates">Updates</string> - <string name="version">Versie</string> - <string name="video_scale">Beeldverhouding</string> - <string name="video">Video</string> - <string name="videos">Video\'s</string> - <string name="watch_live">Live kijken</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> - <string name="create_playlist">Afspeellijst maken</string> - <string name="add_to_playlist">Aan afspeellijst toevoegen</string> - <string name="one_click_pause">Pauzeren met één druk op de knop</string> - <string name="one_click_pause_summary_on">Druk op de middelste knop van het d-pad om af te spelen/te pauzeren</string> - <string name="italic_font">Cursief lettertype gebruiken</string> - <string name="font">Lettertype</string> - <string name="background">Achtergrond</string> - <string name="success">Voltooid</string> - <string name="official_rating">Kijkwijzer</string> - <string name="runtime_sort">Duur</string> - <string name="extras_title">Extra\'s</string> - <plurals name="other_extras"> - <item quantity="one">Overig</item> - <item quantity="other">Overig</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Achter de schermen</item> - <item quantity="other">Achter de schermen</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Titelmuziek</item> - <item quantity="other">Titelmuziek</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Titelvideo</item> - <item quantity="other">Titelvideo\'s</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Clip</item> - <item quantity="other">Clips</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Verwijderde scène</item> - <item quantity="other">Verwijderde scènes</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Interview</item> - <item quantity="other">Interviews</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Scène</item> - <item quantity="other">Scènes</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Voorvertoning</item> - <item quantity="other">Voorvertoningen</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Featurette</item> - <item quantity="other">Featurettes</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Short</item> - <item quantity="other">Shorts</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s download</item> - <item quantity="other">%s downloads</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d uur</item> - <item quantity="other">%d uur</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d item</item> - <item quantity="other">%d items</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d seconde</item> - <item quantity="other">%d seconden</item> - </plurals> - <string name="ac3_supported">Dit apparaat ondersteunt AC3/Dolby Digital</string> - <string name="advanced_settings">Geavanceerde instellingen</string> - <string name="advanced_ui">Uitgebreide interface</string> - <string name="app_theme">Appthema</string> - <string name="auto_check_for_updates">Automatisch controleren op updates</string> - <string name="auto_play_next_delay">Wachttijd alvorens volgende in wachtrij af te spelen</string> - <string name="auto_play_next">Volgende in wachtrij automatisch afspelen</string> - <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 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> - <string name="global_content_scale">Standaard beeldverhouding</string> - <string name="install_update">Update installeren</string> - <string name="installed_version">Geïnstalleerde versie</string> - <string name="max_homepage_items">Max. aantal items op overzicht</string> - <string name="nav_drawer_pins_summary">Kies de te tonen items; andere worden verborgen</string> - <string name="nav_drawer_pins">Zijmenu-items aanpassen</string> - <string name="nav_drawer_switch_on_focus_summary_off">Drukken om van pagina te wisselen</string> - <string name="nav_drawer_switch_on_focus">Van pagina wisselen na focussen</string> - <string name="pass_out_protection">Slaapstandbeveiliging</string> - <string name="play_theme_music">Titelmuziek afspelen</string> - <string name="playback_overrides">Afgedwongen afspeelopties</string> - <string name="remember_selected_tab">Gekozen tabbladen onthouden</string> - <string name="rewatch_next_up">Opnieuw bekijken van items in wachtrij mogelijk maken</string> - <string name="seek_bar_steps">Aantal doorspoelstappen</string> - <string name="send_app_logs_summary">Nuttig bij foutopsporing</string> - <string name="send_app_logs">Applogboeken naar huidige server versturen</string> - <string name="send_crash_reports_summary">De laatstgebruikte server zal worden gekozen</string> - <string name="send_crash_reports">Crashverslagen versturen</string> - <string name="settings">Instellingen</string> - <string name="skip_back_on_resume_preference">Terugspoelen na hervatten</string> - <string name="skip_back_preference">Terugspoelen</string> - <string name="skip_commercials_behavior">Reclame</string> - <string name="skip_forward_preference">Vooruitspoelen</string> - <string name="skip_intro_behavior">Intro\'s</string> - <string name="skip_outro_behavior">Aftitelingen</string> - <string name="skip_previews_behavior">Voorvertoningen</string> - <string name="skip_recap_behavior">Samenvattingen</string> - <string name="update_available">Update beschikbaar</string> - <string name="update_url_summary">URL gebruikt voor het controleren op updates</string> - <string name="update_url">Update-url</string> - <string name="font_size">Tekstgrootte</string> - <string name="font_color">Tekstkleur</string> - <string name="font_opacity">Tekstdoorzichtigheid</string> - <string name="edge_style">Randstijl</string> - <string name="edge_color">Randkleur</string> - <string name="background_opacity">Achtergronddoorzichtigheid</string> - <string name="background_style">Achtergrondstijl</string> - <string name="subtitle_style">Ondertitelstijl</string> - <string name="background_color">Achtergrondkleur</string> - <string name="reset">Standaardwaarden</string> - <string name="bold_font">Vetgedrukt lettertype</string> - <string name="player_backend">Afspeelbackend</string> - <string name="mpv_hardware_decoding">MPV: hardwareversnelling gebruiken</string> - <string name="disable_if_crash">Schakel uit als je crashes ervaart</string> - <string name="mpv_options">MPV-opties</string> - <string name="exoplayer_options">ExoPlayer-opties</string> - <string name="skip_segment_unknown">Segment overslaan</string> - <string name="skip_segment_commercial">Reclame overslaan</string> - <string name="skip_segment_preview">Voorvertoning overslaan</string> - <string name="skip_segment_recap">Samenvatting overslaan</string> - <string name="skip_segment_outro">Aftiteling overslaan</string> - <string name="skip_segment_intro">Intro overslaan</string> - <string name="played">Afgespeeld</string> - <string name="filter">Filteren</string> - <string name="year">Jaar</string> - <string name="decade">Decennium</string> - <string name="remove">Verwijderen</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="subtitle_margin">Marge</string> - <string name="verbose_logging">Uitgebreid logboek aanleggen</string> - <string name="image_cache_size">Omvang van afbeeldingscache (in MB)</string> - <string name="mpv_use_gpu_next">Mpv: gpu-next gebruiken</string> - <string name="mpv_conf">mpv.conf bewerken</string> - <string name="discard_change">Wijzigingen negeren?</string> - <string name="enter_pin">Pincode invoeren</string> - <string name="sign_in_auto">Automatisch inloggen</string> - <string name="use_server_credentials">Inloggen via server</string> - <string name="press_enter_to_confirm">Druk op het midden om te bevestigen</string> - <string name="require_pin_code">Profiel voorzien van pincode</string> - <string name="confirm_pin">Pincode bevestigen</string> - <string name="incorrect">Onjuist</string> - <string name="pin_too_short">De pincode dient 4 of meer tekens te bevatten</string> - <string name="will_remove_pin">De pincode wordt gewist</string> - <string name="view_options">Weergaveopties</string> - <string name="columns">Kolommen</string> - <string name="spacing">Tussenruimte</string> - <string name="aspect_ratio">Beeldverhouding</string> - <string name="show_details">Details bekijken</string> - <string name="image_type">Afbeeldingstype</string> - <string name="cast_and_crew"><![CDATA[Cast en crew]]></string> - <string name="guest_stars">Gastrollen</string> - <string name="edge_size">Randbreedte</string> - <string name="refresh_rate_switching">Beeldfrequentie omschakelen</string> - <string name="automatic">Automatisch</string> - <string name="username_or_password">Gebruikersnaam/wachtwoord gebruiken</string> - <string name="login">Inloggen</string> - <string name="show_titles">Titels tonen</string> - <string name="media_information">Media-informatie</string> - <string name="general">Algemeen</string> - <string name="container">Container</string> - <string name="title">Titel</string> - <string name="codec">Codec</string> - <string name="profile">Profiel</string> - <string name="level">Niveau</string> - <string name="resolution">Resolutie</string> - <string name="anamorphic">Anamorfisch</string> - <string name="interlaced">Geïnterlinieerd</string> - <string name="framerate">Framesnelheid</string> - <string name="bit_depth">Bitdiepte</string> - <string name="video_range">Videobereik</string> - <string name="video_range_type">Soort videobereik</string> - <string name="color_space">Kleurruimte</string> - <string name="color_transfer">Kleuroverdracht</string> - <string name="color_primaries">Voorkeurskleuren</string> - <string name="pixel_format">Pixelformaat</string> - <string name="ref_frames">Referentieframes</string> - <string name="nal">NAL</string> - <string name="language">Taal</string> - <string name="layout">Indeling</string> - <string name="channels">Kanalen</string> - <string name="sample_rate">Samplesnelheid</string> - <string name="avc">AVC</string> - <string name="yes">Ja</string> - <string name="no">Nee</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="live_tv_repeat">Herhalen</string> - <string name="favorite_channels_at_beginning">Favoriete kanalen eerst tonen</string> - <string name="sort_channels_recently_watched">Kanalen sorteren op onlangs bekeken</string> - <string name="color_code_programs">Programma\'s voorzien van kleurcode</string> - <string name="ends_at">Eindigt om %1$s</string> - <string name="enter_server_address">Voer server-adres in</string> - <string name="discover">Ontdekken</string> - <string name="rotate_left">Draai naar links</string> - <string name="rotate_right">Draai naar rechts</string> - <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_title">Favoriete %s</string> - <string name="duration">Duur</string> - <string name="add_row">Rij toevoegen</string> - <string name="for_all_ages">Alle leeftijden</string> - <string name="username">Gebruikersnaam</string> - <string name="up_to_age">Leeftijd tot %s</string> - <string name="saturation">Verzadiging</string> - <string name="guest_star">Gast hoofdrol</string> - <string name="remove_from_queue">Verwijder uit wachtrij</string> - <string name="seerr_login">Login bij Seerr server</string> - <string name="search_for">Zoek %s</string> - <string name="actor">Acteur</string> - <string name="clear_track_choices">Afspeelvoorkeuren wissen</string> - <string name="display_preset_episode_thumbnails">Aflevering thumbnails</string> - <string name="writer">Auteur</string> - <string name="for_episodes">Voor afleveringen</string> - <string name="start_after">Start na</string> - <string name="add_to_queue">Aan wachtrij toevoegen</string> - <string name="composer">Componist</string> - <string name="display_preset_series_thumb">Serie thumbnails</string> - <string name="delete_item">Weet u zeker dat u dit item wilt verwijderen?</string> - <string name="settings_saved">Instellingen opgeslagen</string> - <string name="continue_string">Ga door</string> - <string name="music_videos">Muziekvideos</string> - <string name="max_days_next_up">Max. aantal dagen in Volgende</string> - <string name="now_playing">Wordt nu afgespeeld</string> - <string name="request">Aanvragen</string> - <string name="play_trailer">Trailer afspelen</string> - <string name="no_servers_found">Geen servers gevonden</string> - <string name="only_forced_subtitles">Alleen geforceerde ondertiteling</string> - <string name="retry">Opnieuw proberen</string> - <string name="subtitle_delay">Vertraging ondertiteling</string> - <string name="no_trailers">Geen trailers</string> - <string name="pending">In behandeling</string> - <string name="password">Wachtwoord</string> - <string name="upcoming_movies">Verwachte films</string> - <string name="upcoming_tv">Verwachte series</string> - <string name="request_4k">Aanvragen in 4K</string> - <string name="brightness">Helderheid</string> - <string name="send_media_info_log_to_server">Media-info log naar server</string> - <string name="resolution_switching">Resolutie schakelen</string> - <string name="local">Lokaal</string> - <string name="backdrop_display">Achtergrondstijl</string> - <string name="force_dovi_profile_7_summary">Negeer compatibiliteitscontrole apparaat</string> - <string name="voice_search">Gesproken zoekopdracht</string> - <string name="voice_search_prompt">Spreek om te zoeken</string> - <string name="voice_starting">Starten</string> - <string name="processing">Verwerken</string> - <string name="press_back_to_cancel">Terugknop om te annuleren</string> - <string name="voice_error_audio">Audio opnamefout</string> - <string name="voice_error_client">Spraakherkenningsfout</string> - <string name="voice_error_permissions">Toestemming microfoon vereist</string> - <string name="voice_error_network">Netwerkfout</string> - <string name="voice_error_network_timeout">Netwerk timeout</string> - <string name="voice_error_no_match">Geen spraak herkent</string> - <string name="voice_error_busy">Spraakherkenning bezig</string> - <string name="voice_error_server">Serverfout</string> - <string name="voice_error_speech_timeout">Geen spraak gedetecteerd</string> - <string name="voice_error_unknown">Onbekende fout</string> - <string name="voice_error_start_failed">Fout bij starten spraakherkenning</string> - <string name="voice_error_timeout">Spraakherkenning timed out</string> - <string name="seerr_integration">Seerr integratie</string> - <string name="remove_seerr_server">Verwijder Seerr server</string> - <string name="seerr_server_added">Seerr server toegevoegd</string> - <string name="hdr_subtitle_style">HDR ondertitelstijl</string> - <string name="image_subtitle_opacity">Dekking ondertitelbeeld</string> - <string name="zoom_in">Zoom in</string> - <string name="no_limit">Geen limiet</string> - <string name="customize_home">Startscherm aanpassen</string> - <string name="home_rows">Startrijen</string> - <string name="suggestions_for">Aanbevelingen voor %1$s</string> - <string name="volume_lowest">Laagst</string> - <string name="skip_automatically">Automatisch overslaan</string> - <string name="conductor">Dirigent</string> - <string name="producer">Producent</string> - <plurals name="days"> - <item quantity="one">%s dag</item> - <item quantity="other">%s dagen</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">%s minuut</item> - <item quantity="other">%s minuten</item> - </plurals> - <string name="quick_connect_summary">Een ander apparaat toestaan om in te loggen op uw account</string> - <string name="quick_connect_code">Quick Connect code invoeren</string> - <string name="quick_connect_code_error">Code moet 6 cijfers bevatten</string> - <string name="quick_connect_success">Apparaat succesvol geautoriseerd</string> - <string name="upgrade_mpv_toast">MPV is nu de standaard speler behalve voor HDR. \nU kunt dit in de instellingen wijzigen.</string> - <string name="save_for_album">Bewaar voor album</string> - <string name="play_slideshow">Diavoorstelling afspelen</string> - <string name="stop_slideshow">Diavoorstelling stoppen</string> - <string name="slideshow_at_beginning">Aan het begin</string> - <string name="no_more_images">Geen foto\'s meer</string> - <string name="slideshow_duration">Duur diavoorstelling</string> - <string name="play_videos_during_slideshow">Speel video\'s tijdens diavoorstelling</string> - <string name="artists">Artiesten</string> - <string name="songs">Nummers</string> - <string name="increase_all_cards_size">Alle kaarten vergroten</string> - <string name="decrease_all_cards_size">Alle kaarten verkleinen</string> - <string name="use_thumb_images">Kleine afbeeldingen gebruiken</string> - <string name="display_presets">Weergavevoorkeuren</string> - <string name="display_presets_description">Ingebouwde presets om snel alle rijen te stijlen</string> - <string name="customize_home_summary">Kies rijen en afbeeldingen op startscherm</string> - <string name="animate">Animeer</string> - <string name="max_age_rating">Max leeftijdsclassificatie</string> - <string name="no_max">Geen max</string> - <string name="screensaver">Schermbeveiliging</string> - <string name="screensaver_settings">Instellingen schermbeveiliging</string> - <string name="start_screensaver">Start schermbeveiliging</string> - <string name="photos">Foto\'s</string> - <string name="include_types">Soort toevoegen</string> - <string name="include_types_summary">Welke soorten afbeeldingen moeten worden getoond</string> - <string name="content_scale_fit">Passend</string> - <string name="content_scale_crop">Bijsnijden</string> - <string name="content_scale_fill">Vullen</string> - <string name="content_scale_fill_width">Vulbreedte</string> - <string name="content_scale_fill_height">Vulhoogte</string> - <string name="display_preset_default">Wolfskin standaard</string> - <string name="display_preset_compact">Wolfskin compact</string> - <string name="volume_low">Laag</string> - <string name="volume_medium">Middel</string> - <string name="volume_high">Hoog</string> - <string name="volume_full">Volledig</string> - <string name="purple">Paars</string> - <string name="orange">Oranje</string> - <string name="bold_blue">Felblauw</string> - <string name="black">Zwart</string> - <string name="ffmpeg_fallback">Alleen FFmpeg gebruiken als er geen ingebouwde decoder is</string> - <string name="ffmpeg_prefer">Voorkeur voor FFmpeg boven ingebouwde decoders</string> - <string name="ffmpeg_never">Nooit FFmpeg decoders gebruiken</string> - <string name="next_up_playback_end">Aan het einde van de weergave</string> - <string name="next_up_outro">Tijdens aftiteling/outro</string> - <string name="white">Wit</string> - <string name="light_gray">Lichtgrijs</string> - <string name="dark_gray">Donkergrijs</string> - <string name="yellow">Geel</string> - <string name="cyan">Cyaan</string> - <string name="subtitle_edge_outline">Omtrek</string> - <string name="subtitle_edge_shadow">Schaduw</string> - <string name="prefer_mpv">Voorkeur voor MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer voor HDR gebruiken</string> - <string name="aspect_ratios_square">Vierkant (1:1)</string> - <string name="image_type_primary">Primair</string> - <string name="image_type_thumb">Miniatuur</string> - <string name="backdrop_style_dynamic">Afbeelding met dynamische kleuren</string> - <string name="backdrop_style_image">Alleen afbeelding</string> - <string name="seerr_jellyfin_user">Jellyfin gebruiker</string> - <string name="seerr_local_user">Lokale gebruiker</string> - <string name="no_subtitles_found">Geen externe ondertiteling gevonden</string> - <string name="series_continueing">Huidig</string> - <string name="in_app_screensaver">In-app schermbeveiliging gebruiken</string> - <string name="show_media_management">Media-management opties tonen</string> - <string name="lyricist">Tekstschrijver</string> - <string name="arranger">Arrangeur</string> - <string name="engineer">Technicus</string> - <string name="creator">Bedenker</string> - <string name="artist">Artiest</string> - <string name="album_artist">Album artiest</string> - <string name="popular_songs">Populaire nummers</string> - <string name="play_next">Volgende afspelen</string> - <string name="lyrics">Tekst</string> - <string name="hide_lyrics">Tekst verbergen</string> - <string name="show_lyrics">Tekst tonen</string> - <string name="song_has_lyrics">Nummer bevat tekst</string> - <string name="show_album_cover">Albumhoes tonen</string> - <string name="show_visualizer">Visualisatie tonen</string> - <string name="show_backdrop">Achtergrond tonen</string> - <string name="play_as_type">Afspelen als?</string> - <string name="visualizer_rationale">Visualisatie van de huidig afspelende audio vereist toestemming voor opname.</string> - <string name="prefer_logos">Titels als logo weergeven</string> - <string name="select_all">Alles selecteren</string> - <string name="separate_types">Afzonderlijke types</string> - <string name="remove_continue_watching">Uit Verder kijken verwijderen</string> - <string name="remove_next_up">Uit Volgende verwijderen</string> - <string name="view_more">Bekijk meer</string> - <string name="discover_tv">Series ontdekken</string> - <string name="discover_movies">Films ontdekken</string> - <string name="view_removed_next_up">Uit Volgende verwijderd bekijken</string> - <string name="skip_behavior">Overslaan</string> - <string name="skip_behavior_summary">Voor intro\'s, aftiteling, etc</string> - <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">Wolfskin 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 deleted file mode 100644 index eb7a6fe7..00000000 --- a/app/src/main/res/values-nn/strings.xml +++ /dev/null @@ -1,547 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Om</string> - <string name="active_recordings">Aktive opptak</string> - <string name="add_favorite">Favoritt</string> - <string name="add_server">Legg til server</string> - <string name="add_user">Legg til brukar</string> - <string name="audio">Lyd</string> - <string name="birthplace">Fødestad</string> - <string name="bitrate">Bitrate</string> - <string name="born">Fødd</string> - <string name="cancel_recording">Avbryt opptak</string> - <string name="cancel_series_recording">Avbryt opptak av serie</string> - <string name="cancel">Avbryt</string> - <string name="chapters">Kapittel</string> - <string name="choose_stream">Vel %1$s</string> - <string name="collection">Samling</string> - <string name="collections">Samlingar</string> - <string name="confirm">Bekreft</string> - <string name="default_track">Standard</string> - <string name="delete">Slett</string> - <string name="died">Dødd</string> - <string name="directed_by">Regissert av %1$s</string> - <string name="ac3_supported">Eininga støttar AC3/Dolby Digital</string> - <string name="clear_image_cache">Tøm biletmellomlager</string> - <string name="commercial">Reklame</string> - <string name="community_rating">Brukarvurdering</string> - <string name="compose_error_message_title">Ein feil har oppstått! Trykk på knappen for å sende loggar til servaren din.</string> - <string name="continue_watching">Hald fram med å sjå</string> - <string name="critic_rating">Meldevurdering</string> - <string name="decimal_seconds">%.1f sekund</string> - <string name="director">Regissør</string> - <string name="disabled">Deaktivert</string> - <string name="discovered_servers">Funne servarar</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> - <string name="enter_server_address">Oppgi servaradresse</string> - <string name="episodes">Episodar</string> - <string name="error_loading_collection">Feil ved lasting av samling %1$s</string> - <string name="external_track">Ekstern</string> - <string name="favorites">Favorittar</string> - <string name="file_size">Storleik</string> - <string name="forced_track">Tvungen</string> - <string name="genres">Sjangrar</string> - <string name="go_to_series">Gå til serie</string> - <string name="go_to">Gå til</string> - <string name="hide_controller_timeout">Gøym avspelingskontrollar</string> - <string name="hide_debug_info">Gøym feilsøkingsinfo</string> - <string name="hide">Gøym</string> - <string name="home">Heim</string> - <string name="immediate">Omgåande</string> - <string name="intro">Intro</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string> - <string name="library">Bibliotek</string> - <string name="license_info">Lisensinformasjon</string> - <string name="live_tv">Direkte-TV</string> - <string name="loading">Lastar…</string> - <string name="mark_entire_series_as_played">Marker heile serien som sett?</string> - <string name="mark_entire_series_as_unplayed">Marker heile serien som usett?</string> - <string name="mark_unwatched">Marker som usett</string> - <string name="mark_watched">Marker som sett</string> - <string name="max_bitrate">Maks bitrate</string> - <string name="more_like_this">Meir som dette</string> - <string name="more">Meir</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> - <string name="no_results">Ingen resultat</string> - <string name="no_servers_found">Ingen servarar funne</string> - <string name="no_scheduled_recordings">Ingen planlagde opptak</string> - <string name="no_update_available">Ingen oppdatering tilgjengeleg</string> - <string name="outro">Outro</string> - <string name="path">Filveg</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> - <string name="playback_debug_info">Vis feilsøkingsinfo for avspeling</string> - <string name="playback_speed">Avspelingsfart</string> - <string name="playback">Avspeling</string> - <string name="playlist">Speleliste</string> - <string name="playlists">Spelelister</string> - <string name="preview">Førehandsvising</string> - <string name="profile_specific_settings">Innstillingar for brukarprofil</string> - <string name="queue">Kø</string> - <string name="recap">Samandrag</string> - <string name="recently_added_in">Sist lagt til i %1$s</string> - <string name="recently_added">Sist lagt til</string> - <string name="recently_recorded">Sist tatt opp</string> - <string name="recently_released">Sist utgitt</string> - <string name="recommended">Tilrådd</string> - <string name="record_program">Ta opp program</string> - <string name="record_series">Ta opp serie</string> - <string name="remove_favorite">Fjern favoritt</string> - <string name="restart">Start på nytt</string> - <string name="resume">Hald fram</string> - <string name="save">Lagre</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> - <string name="select_user">Vel brukar</string> - <string name="show_debug_info">Vis feilsøkingsinfo</string> - <string name="show_next_up_when">Vis \'Neste\'</string> - <string name="show">Serie</string> - <string name="shuffle">Tilfeldig rekkjefølgje</string> - <string name="skip">Hopp over</string> - <string name="sort_by_date_added">Dato lagt til</string> - <string name="sort_by_date_episode_added">Dato episode lagt til</string> - <string name="sort_by_date_played">Dato spela av</string> - <string name="sort_by_date_released">Utginningsdato</string> - <string name="sort_by_name">Namn</string> - <string name="sort_by_random">Tilfeldig</string> - <string name="studios">Studio</string> - <string name="submit">Send inn</string> - <string name="subtitle">Undertekst</string> - <string name="subtitles">Undertekstar</string> - <string name="suggestions">Forslag</string> - <string name="switch_servers">Byt servar</string> - <string name="switch_user">Byt brukar</string> - <string name="top_unwatched">Best vurderte usette</string> - <plurals name="trailers"> - <item quantity="one">Trailerar</item> - <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_title">TV-seriar</string> - <string name="ui_interface">Grensesnitt</string> - <string name="unknown">Ukjend</string> - <string name="updates">Oppdateringar</string> - <string name="version">Versjon</string> - <string name="video_scale">Videoskalering</string> - <string name="video">Video</string> - <string name="videos">Videoar</string> - <string name="watch_live">Sjå direkte</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> - <string name="aired_episode_order">Rekkjefølgje etter sendedato</string> - <string name="create_playlist">Lag ny speleliste</string> - <string name="add_to_playlist">Legg til i speleliste</string> - <string name="one_click_pause">Pause med eitt klikk</string> - <string name="one_click_pause_summary_on">Trykk i midten på styreputa for å pause/spele av</string> - <string name="italic_font">Kursiv skrift</string> - <string name="font">Skrifttype</string> - <string name="background">Bakgrunn</string> - <string name="success">Fullført</string> - <string name="official_rating">Aldersgrense</string> - <string name="runtime_sort">Speltid</string> - <string name="extras_title">Ekstra</string> - <plurals name="other_extras"> - <item quantity="one">Anna</item> - <item quantity="other">Anna</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Bak kulissane</item> - <item quantity="other">Bak kulissane</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Temasongar</item> - <item quantity="other">Temasongar</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Temavideoar</item> - <item quantity="other">Temavideoar</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klipp</item> - <item quantity="other">Klipp</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Sletta sener</item> - <item quantity="other">Sletta sener</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Intervju</item> - <item quantity="other">Intervju</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Scener</item> - <item quantity="other">Scener</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Smakebitar</item> - <item quantity="other">Smakebitar</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Bakomfilmar</item> - <item quantity="other">Bakomfilmar</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Kortfilmar</item> - <item quantity="other">Kortfilmar</item> - </plurals> - <string name="advanced_settings">Avanserte innstillingar</string> - <string name="advanced_ui">Avansert grensesnitt</string> - <string name="app_theme">App-tema</string> - <string name="auto_check_for_updates">Sjekk automatisk etter oppdateringar</string> - <string name="auto_play_next_delay">Forseinking før neste vert spela av</string> - <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">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> - <string name="ffmpeg_extension_pref">Bruk FFmpeg-dekodermodul</string> - <string name="global_content_scale">Standard innhaldsskalering</string> - <string name="install_update">Installer oppdatering</string> - <string name="installed_version">Installert versjon</string> - <string name="max_homepage_items">Maks element på rader på heimskjermen</string> - <string name="nav_drawer_pins">Tilpass element i navigasjonsskuffa</string> - <string name="nav_drawer_switch_on_focus_summary_off">Klikk for å byte side</string> - <string name="nav_drawer_switch_on_focus">Byt side i navigasjonsskuffa ved fokus</string> - <string name="pass_out_protection">Svevn-beskyttelse</string> - <string name="play_theme_music">Spel temamusikk</string> - <string name="playback_overrides">Overstyring av avspeling</string> - <string name="remember_selected_tab">Hugs valde faner</string> - <string name="rewatch_next_up">Tillat gjensjåing i \'Neste\'</string> - <string name="seek_bar_steps">Steglengde for søkelinje</string> - <string name="send_app_logs_summary">Nyttig for feilsøking</string> - <string name="send_app_logs">Send app-loggar til gjeldande servar</string> - <string name="send_crash_reports_summary">Vil prøve å sende til sist tilkopla servar</string> - <string name="send_crash_reports">Send krasjrapportar</string> - <string name="settings">Innstillingar</string> - <string name="skip_back_on_resume_preference">Hopp tilbake ved framhald av avspeling</string> - <string name="skip_back_preference">Hopp tilbake</string> - <string name="skip_commercials_behavior">Handling for overhopping av reklame</string> - <string name="skip_forward_preference">Hopp framover</string> - <string name="skip_intro_behavior">Handling for overhopping av intro</string> - <string name="skip_outro_behavior">Handling for overhopping av outro</string> - <string name="skip_previews_behavior">Handling for overhopping av førehandsvising</string> - <string name="skip_recap_behavior">Handling for overhopping av samandrag</string> - <string name="update_available">Oppdatering tilgjengeleg</string> - <string name="update_url_summary">URL for sjekk av app-oppdateringar</string> - <string name="update_url">URL for oppdatering</string> - <string name="font_size">Skriftstorleik</string> - <string name="font_color">Skriftfarge</string> - <string name="font_opacity">Gjennomsiktigheit for skrift</string> - <string name="edge_style">Kantstil</string> - <string name="edge_color">Kantfarge</string> - <string name="background_opacity">Gjennomsiktigheit for bakgrunn</string> - <string name="background_style">Bakgrunnsstil</string> - <string name="subtitle_style">Stil for undertekst</string> - <string name="background_color">Bakgrunnsfarge</string> - <string name="reset">Nullstill</string> - <string name="bold_font">Feit skrift</string> - <string name="player_backend">Avspelingsmotor</string> - <string name="mpv_hardware_decoding">MPV: Bruk maskinvaredekoding</string> - <string name="disable_if_crash">Deaktiver dersom appen krasjar</string> - <string name="mpv_options">MPV-val</string> - <string name="exoplayer_options">ExoPlayer-val</string> - <string name="skip_segment_unknown">Hopp over segment</string> - <string name="skip_segment_commercial">Hopp over reklame</string> - <string name="skip_segment_preview">Hopp over førehandsvising</string> - <string name="skip_segment_recap">Hopp over samandrag</string> - <string name="skip_segment_outro">Hopp over outro</string> - <string name="skip_segment_intro">Hopp over intro</string> - <string name="played">Sett</string> - <string name="filter">Filter</string> - <string name="year">År</string> - <string name="decade">Tiår</string> - <string name="remove">Fjern</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Bruk gpu-next</string> - <string name="mpv_conf">Rediger mpv.conf</string> - <string name="discard_change">Forkast endringar?</string> - <string name="subtitle_margin">Marg</string> - <string name="verbose_logging">Detaljert logging</string> - <string name="enter_pin">Tast inn PIN</string> - <string name="sign_in_auto">Logg inn automatisk</string> - <string name="use_server_credentials">Logg inn via servar</string> - <string name="press_enter_to_confirm">Trykk i midten for å bekrefte</string> - <string name="require_pin_code">Krev PIN for profil</string> - <string name="confirm_pin">Bekreft PIN</string> - <string name="incorrect">Feil</string> - <string name="pin_too_short">PIN må vere 4 tastar eller lenger</string> - <string name="will_remove_pin">Vil fjerne PIN</string> - <string name="image_cache_size">Storleik på biletmellomlager (MB)</string> - <string name="view_options">Visingsval</string> - <string name="columns">Kolonner</string> - <string name="spacing">Mellomrom</string> - <string name="aspect_ratio">Bileteforhold</string> - <string name="show_details">Vis detaljar</string> - <string name="image_type">Bilettype</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> - <string name="automatic">Automatisk</string> - <string name="username_or_password">Bruk brukarnamn/passord</string> - <string name="login">Logg inn</string> - <string name="general">Generelt</string> - <string name="container">Containar</string> - <string name="title">Tittel</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Nivå</string> - <string name="resolution">Oppløysing</string> - <string name="anamorphic">Anamorfisk</string> - <string name="interlaced">Linjefletta</string> - <string name="framerate">Bildefrekvens</string> - <string name="bit_depth">Bit-dybde</string> - <string name="video_range">Videoområde</string> - <string name="video_range_type">Type videoområde</string> - <string name="color_space">Fargerom</string> - <string name="color_transfer">Fargeoverføring</string> - <string name="color_primaries">Fargeprimærar</string> - <string name="pixel_format">Pikselformat</string> - <string name="ref_frames">Referanserammer</string> - <string name="nal">NAL</string> - <string name="language">Språk</string> - <string name="layout">Layout</string> - <string name="channels">Kanalar</string> - <string name="sample_rate">Samplingsfrekvens</string> - <string name="avc">AVC</string> - <string name="yes">Ja</string> - <string name="no">Nei</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Vis titlar</string> - <string name="live_tv_repeat">Repeter</string> - <string name="favorite_channels_at_beginning">Vis favorittkanalar først</string> - <string name="sort_channels_recently_watched">Sorter kanalar etter sist sett</string> - <string name="color_code_programs">Fargekod program</string> - <string name="subtitle_delay">Undertekstforseinking</string> - <string name="backdrop_display">Bakgrunnsstil</string> - <string name="none">Ingen</string> - <string name="subtitle_download_too_long">Nedlastinga tek lang tid, du må kanskje starte avspelinga på nytt</string> - <plurals name="downloads"> - <item quantity="one">%s nedlasting</item> - <item quantity="other">%s nedlastingar</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d time</item> - <item quantity="other">%d timar</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d element</item> - <item quantity="other">%d elementer</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekund</item> - <item quantity="other">%d sekund</item> - </plurals> - <string name="nav_drawer_pins_summary">Vel standardelementa som skal visast, andre vert skjulde</string> - <string name="ends_at">Sluttar %1$s</string> - <string name="only_forced_subtitles">Berre påtvinga undertekstar</string> - <string name="voice_search">Stemmesøk</string> - <string name="voice_starting">Startar</string> - <string name="voice_search_prompt">Snakk for å søkje</string> - <string name="processing">Jobbar</string> - <string name="press_back_to_cancel">Trykk tilbake for å avbryta</string> - <string name="voice_error_audio">Lydopptaksfeil</string> - <string name="voice_error_client">Feil med talegjenkjenning</string> - <string name="voice_error_permissions">Treng mikrofontilgang</string> - <string name="voice_error_network">Nettverksfeil</string> - <string name="voice_error_network_timeout">Tidsavbrot på nettverket</string> - <string name="voice_error_no_match">Fann inga tale</string> - <string name="voice_error_busy">Talegjenkjenning er oppteken</string> - <string name="voice_error_server">Tenarfeil</string> - <string name="voice_error_speech_timeout">Inga tale funne</string> - <string name="voice_error_unknown">Ukjend feil</string> - <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_title">Favoritt %s</string> - <string name="resolution_switching">Oppløysingsbyte</string> - <string name="local">Lokal</string> - <string name="play_trailer">Spel trailer</string> - <string name="no_trailers">Ingen trailerar</string> - <string name="clear_track_choices">Tøm sporval</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">Direkteavspeling av Dolby Vision-profil 7</string> - <string name="force_dovi_profile_7_summary">Ignorerer kompatibilitetssjekk på eininga</string> - <string name="discover">Oppdag</string> - <string name="request">Be om</string> - <string name="pending">Ventar</string> - <string name="seerr_integration">Seerr-integrasjon</string> - <string name="remove_seerr_server">Fjern Seerr-tenar</string> - <string name="seerr_server_added">Seerr-tenar lagt til</string> - <string name="quick_connect">Hurtigkopling</string> - <string name="quick_connect_summary">Gi ei anna eining tilgang til å logge inn på kontoen din</string> - <string name="quick_connect_code">Skriv inn hurtigkoplingskode</string> - <string name="quick_connect_code_error">Koden må vere 6 siffer</string> - <string name="quick_connect_success">Eininga fekk tilgang</string> - <string name="password">Passord</string> - <string name="username">Brukarnamn</string> - <string name="url">URL</string> - <string name="trending">Populært no</string> - <string name="upcoming_movies">Kommande filmar</string> - <string name="upcoming_tv">Kommande TV-seriar</string> - <string name="request_4k">Be om i 4K</string> - <string name="software_decoding_av1">AV1-programvaredekoding</string> - <string name="hdr_subtitle_style">HDR-tekststil</string> - <string name="image_subtitle_opacity">Gjennomsiktigheit for bilettekst</string> - <string name="brightness">Lysstyrke</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Metning</string> - <string name="hue">Fargetone</string> - <string name="red">Raud</string> - <string name="green">Grøn</string> - <string name="blue">Blå</string> - <string name="blur">Sløring</string> - <string name="save_for_album">Lagre til album</string> - <string name="play_slideshow">Spel lysbiletevising</string> - <string name="stop_slideshow">Stopp lysbiletevising</string> - <string name="slideshow_at_beginning">Ved byrjinga</string> - <string name="no_more_images">Ingen fleire bilete</string> - <string name="rotate_left">Roter til venstre</string> - <string name="rotate_right">Roter til høgre</string> - <string name="zoom_in">Zoom inn</string> - <string name="zoom_out">Zoom ut</string> - <string name="slideshow_duration">Lengde på lysbiletevising</string> - <string name="play_videos_during_slideshow">Spel videoar i lysbiletevisinga</string> - <string name="send_media_info_log_to_server">Send logg med medieinfo til tenaren</string> - <string name="no_limit">Ingen grense</string> - <string name="max_days_next_up">Maks dagar i Neste</string> - <string name="add_row">Legg til rad</string> - <string name="genres_in">Sjangrar i %1$s</string> - <string name="recently_released_in">Nylig utgitt i %1$s</string> - <string name="height">Høgde</string> - <string name="apply_all_rows">Bruk på alle radene</string> - <string name="customize_home">Tilpass heim-sida</string> - <string name="home_rows">Rader på heim-sida</string> - <string name="load_from_server">Last frå brukarprofil på tenaren</string> - <string name="save_to_server">Lagre til brukarprofil på tenaren</string> - <string name="load_from_web_client">Last frå nettklient</string> - <string name="use_series">Bruk bilete av serien</string> - <string name="add_row_for">Legg til rad for %1$s</string> - <string name="overwrite_server_settings">Overskrive innstillingane på tenaren?</string> - <string name="overwrite_local_settings">Overskrive lokale innstillingar?</string> - <string name="for_episodes">For episodar</string> - <string name="suggestions_for">Forslag for %1$s</string> - <string name="increase_all_cards_size">Auk storleiken på alle korta</string> - <string name="decrease_all_cards_size">Mink storleiken på alle korta</string> - <string name="use_thumb_images">Bruk miniatyrbilete</string> - <string name="settings_saved">Innstillingar lagra</string> - <string name="display_presets">Visingsval</string> - <string name="display_presets_description">Innebygde val for å raskt endre stilen på alle radene</string> - <string name="customize_home_summary">Vel rader og bilete på heim-sida</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 maksgrense</string> - <string name="for_all_ages">For alle aldrar</string> - <string name="up_to_age">Opp til %s år</string> - <string name="screensaver">Skjermsparar</string> - <string name="screensaver_settings">Innstillingar for skjermsparar</string> - <string name="start_screensaver">Start skjermsparar</string> - <string name="duration">Varigheit</string> - <string name="photos">Bilete</string> - <string name="include_types">Inkluder typar</string> - <string name="include_types_summary">Kva typar element det skal visast bilete for</string> - <string name="content_scale_fit">Tilpass</string> - <string name="content_scale_crop">Beskjer</string> - <string name="content_scale_fill">Fyll</string> - <string name="content_scale_fill_width">Fyll breidde</string> - <string name="content_scale_fill_height">Fyll høgde</string> - <string name="display_preset_default">Wolfskin-standard</string> - <string name="display_preset_compact">Wolfskin-kompakt</string> - <string name="display_preset_series_thumb">Miniatyrbilete for seriar</string> - <string name="display_preset_episode_thumbnails">Miniatyrbilete for episodar</string> - <string name="volume_lowest">Lågast</string> - <string name="volume_low">Låg</string> - <string name="volume_medium">Middels</string> - <string name="volume_high">Høg</string> - <string name="volume_full">Full</string> - <string name="purple">Lilla</string> - <string name="orange">Oransje</string> - <string name="bold_blue">Kraftig blå</string> - <string name="black">Svart</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">Berre bruk FFmpeg om det ikkje finst innebygd dekodar</string> - <string name="ffmpeg_prefer">Føretrekk FFmpeg framfor innebygde dekodarar</string> - <string name="ffmpeg_never">Aldri bruk FFmpeg-dekodarar</string> - <string name="next_up_playback_end">Ved slutten av avspelinga</string> - <string name="next_up_outro">Under rulletekst/outro</string> - <string name="white">Kvit</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">Omriss</string> - <string name="subtitle_edge_shadow">Skugge</string> - <string name="background_style_wrap">Omkransa</string> - <string name="background_style_boxed">Rektangulær</string> - <string name="prefer_mpv">Føretrekk MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Bruk ExoPlayer for HDR-avspeling</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="backdrop_style_dynamic">Bilete med dynamisk farge</string> - <string name="backdrop_style_image">Berre bilete</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">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> - <string name="delete_item">Er du sikker på at du vil sletta dette elementet?</string> - <string name="show_media_management">Vis alternativ for mediehandsaming</string> - <string name="actor">Skodespelar</string> - <string name="composer">Komponist</string> - <string name="writer">Forfattar</string> - <string name="guest_star">Gjestestjerne</string> - <string name="producer">Produsent</string> - <string name="conductor">Dirigent</string> - <string name="lyricist">Tekstforfattar</string> - <string name="arranger">Arrangør</string> - <string name="engineer">Teknikar</string> - <string name="mixer">Miksar</string> - <string name="creator">Skapar</string> - <string name="artist">Artist</string> - <string name="search_for">Søk %s</string> - <string name="select_all">Vel alle</string> -</resources> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml deleted file mode 100644 index 3bb8567e..00000000 --- a/app/src/main/res/values-pl/strings.xml +++ /dev/null @@ -1,808 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="add_server">Dodaj serwer</string> - <string name="add_user">Dodaj użytkownika</string> - <string name="audio">Dźwięk</string> - <string name="birthplace">Miejsce urodzenia</string> - <string name="bitrate">Bitrate</string> - <string name="confirm">Potwierdź</string> - <string name="continue_watching">Kontynuuj oglądanie</string> - <string name="critic_rating">Ocena krytyków</string> - <string name="downloading">Pobieranie…</string> - <string name="episodes">Odcinki</string> - <string name="library">Biblioteka</string> - <string name="license_info">Informacje o licencji</string> - <string name="loading">Ładowanie…</string> - <string name="mark_entire_series_as_played">Oznaczyć serial jako odtworzony?</string> - <string name="mark_entire_series_as_unplayed">Oznaczyć cały sezon jako nieodtworzony?</string> - <string name="mark_unwatched">Oznacz jako nieobejrzane</string> - <string name="mark_watched">Oznacz jako obejrzane</string> - <string name="max_bitrate">Maksymalny bitrate</string> - <string name="more_like_this">Podobne</string> - <string name="more">Więcej</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> - <string name="no_scheduled_recordings">Brak zaplanowanych nagrań</string> - <string name="no_update_available">Brak dostępnych aktualizacji</string> - <string name="playback_speed">Prędkość odtwarzania</string> - <string name="playback">Odtwarzanie</string> - <string name="recommended">Polecane</string> - <string name="record_program">Zapisz program</string> - <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_title">Seriale</string> - <string name="tv_season">Sezon</string> - <string name="tv_seasons">Sezony</string> - <string name="subtitle">Napisy</string> - <string name="subtitles">Napisy</string> - <string name="suggestions">Propozycje</string> - <string name="switch_servers">Zmień serwer</string> - <string name="switch_user">Zmień</string> - <string name="top_unwatched">Najlepiej oceniane nieobejrzane</string> - <plurals name="trailers"> - <item quantity="one">Zwiastun</item> - <item quantity="few">Zwiastuny</item> - <item quantity="many">Zwiastunów</item> - <item quantity="other">Zwiastunów</item> - </plurals> - <string name="submit">Potwierdź</string> - <string name="sort_by_date_released">Data wydania</string> - <string name="sort_by_name">Nazwa</string> - <string name="sort_by_random">Losowy</string> - <string name="tv_guide">Przewodnik TV</string> - <string name="studios">Studia filmowe</string> - <string name="shuffle">Losuj</string> - <string name="show_next_up_when">Pokaż następne</string> - <string name="recently_released">Ostatnio wydane</string> - <string name="play">Odtwórz</string> - <string name="playback_debug_info">Pokaż informacje debugowania odtwarzacza</string> - <string name="outro">Napisy końcowe</string> - <string name="path">Ścieżka</string> - <string name="playlists">Playlisty</string> - <string name="preview">Podgląd</string> - <string name="profile_specific_settings">Ustawienia Profilu Użytkownika</string> - <string name="queue">Kolejka</string> - <string name="save">Zapisz</string> - <string name="search">Szukaj</string> - <string name="searching">Wyszukiwanie…</string> - <string name="recently_added">Ostatnio dodane</string> - <string name="recently_recorded">Ostatnio Nagrane</string> - <string name="recently_added_in">Ostatnio dodane w %1$s</string> - <string name="resume">Wznów</string> - <string name="sort_by_date_played">Data odtworzenia</string> - <string name="add_favorite">Ulubione</string> - <string name="cancel">Anuluj</string> - <string name="chapters">Rozdziały</string> - <string name="choose_stream">Wybierz %1$s</string> - <string name="clear_image_cache">Wyczyść pamięć podręczną obrazów</string> - <string name="community_rating">Ocena społeczności</string> - <string name="compose_error_message_title">Wystąpił błąd. Nacisnij przycisk żeby wysłać logi na Twój serwer.</string> - <string name="director">Reżyser</string> - <string name="genres">Gatunek</string> - <string name="go_to_series">Idź do serialu</string> - <string name="go_to">Idż do</string> - <string name="hide_controller_timeout">Ukryj przyciski odtwarzania</string> - <string name="hide_debug_info">Ukryj informacje o debugowaniu</string> - <string name="hide">Ukryj</string> - <string name="home">Strona główna</string> - <string name="favorites">Ulubione</string> - <string name="delete">Usuń</string> - <string name="default_track">Domyślna ścieżka</string> - <string name="discovered_servers">Odnalezione serwery</string> - <string name="enter_server_url">Wprowadż IP serwera lub adres URL</string> - <string name="file_size">Rozmiar</string> - <string name="forced_track">Wymuszona ścieżka</string> - <string name="intro">Wstęp</string> - <string name="name">Nazwa</string> - <string name="remove_favorite">Usuń z ulubionych</string> - <string name="restart">Uruchom ponownie</string> - <string name="select_server">Wybierz serwer</string> - <string name="select_user">Wybierz użytkownika</string> - <string name="show_debug_info">Pokaż infomacje o debugowaniu</string> - <string name="username_or_password">Użyj nazwy użytkownika/hasła</string> - <string name="login">Login</string> - <string name="columns">Kolumny</string> - <string name="spacing">Odstępy</string> - <string name="show_details">Pokaż szczegóły</string> - <string name="enter_pin">Wprowadź PIN</string> - <string name="subtitle_margin">Odstęp</string> - <string name="year">Rok</string> - <string name="decade">Dekada</string> - <string name="remove">Usuń</string> - <string name="live_tv">Telewizja</string> - <string name="play_with_transcoding">Odtwórz z transkodowaniem</string> - <string name="show_clock">Pokaż zegar</string> - <string name="font">Czcionka</string> - <string name="background">Tło</string> - <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">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">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> - <item quantity="few">Czołówki</item> - <item quantity="many">Czołówek</item> - <item quantity="other">Czołówek</item> - </plurals> - <string name="create_playlist">Utwórz nową playliste</string> - <string name="add_to_playlist">Dodaj do playlisty</string> - <string name="version">Wersja</string> - <string name="updates">Aktualizacje</string> - <string name="cancel_recording">Anuluj nagrywanie</string> - <string name="view_options">Zobacz opcje</string> - <string name="confirm_pin">Potwierdź PIN</string> - <string name="incorrect">Nieprawidłowe</string> - <string name="pin_too_short">PIN musi zawierać 4 lub więcej znaków</string> - <string name="require_pin_code">Wymagaj PIN dla profilu</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: użyj gpu-next</string> - <string name="mpv_conf">Edytuj mpv.conf</string> - <string name="discard_change">Odrzucić zmiany?</string> - <string name="press_enter_to_confirm">Naciśnij środek aby potwierdzić</string> - <string name="image_cache_size">Rozmiar pamięci tymczasowej obrazów (MB)</string> - <string name="disable_if_crash">Wyłącz jeśli doświadczasz problemów</string> - <string name="mpv_options">Opcje MPV</string> - <string name="exoplayer_options">Opcje odtwarzacza ExoPlayer</string> - <string name="skip_segment_unknown">Pomiń segmenty</string> - <string name="skip_segment_commercial">Pomiń reklamy</string> - <string name="skip_segment_intro">Pomiń czołówkę</string> - <string name="skip_segment_outro">Pomiń napisy końcowe</string> - <string name="sign_in_auto">Loguj automatycznie</string> - <string name="reset">Reset</string> - <string name="bold_font">Czcionka pogrubiona</string> - <string name="edge_style">Styl obramowania</string> - <string name="edge_color">Kolor obramowania</string> - <string name="background_opacity">Przezroczystość tła</string> - <string name="background_style">Styl tła</string> - <string name="subtitle_style">Styl napisów</string> - <string name="background_color">Kolor tła</string> - <string name="update_url">Uaktualnij adres URL</string> - <string name="font_size">Rozmiar czcionki</string> - <string name="font_color">Kolor czcionki</string> - <string name="settings">Ustawienia</string> - <string name="send_crash_reports">Wyślij raport o błędach</string> - <string name="update_available">Dostępna aktualizacja</string> - <string name="mpv_hardware_decoding">MPV: używaj dekodowania sprzętowego</string> - <string name="filter">Filtruj</string> - <string name="italic_font">Czcionka kursywy</string> - <plurals name="behind_the_scenes"> - <item quantity="one">Za kulisami</item> - <item quantity="few">Za kulisami</item> - <item quantity="many">Za kulisami</item> - <item quantity="other">Za kulisami</item> - </plurals> - <string name="advanced_settings">Ustawienia zaawansowane</string> - <string name="check_for_updates">Sprawdź dostępność aktualizacji</string> - <string name="auto_check_for_updates">Automatycznie sprawdzaj dostępność aktualizacji</string> - <string name="install_update">Zainstaluj aktualizacje</string> - <string name="installed_version">Zainstalowana wersja</string> - <string name="ffmpeg_extension_pref">Użyj dekodera FFmpeg</string> - <string name="edge_size">Rozmiar ramki</string> - <string name="about">O Wholphin</string> - <string name="active_recordings">Nagrania w toku</string> - <string name="born">Data urodzenia</string> - <string name="cancel_series_recording">Anuluj nagrywanie serialu</string> - <string name="collection">Kolekcja</string> - <string name="collections">Kolekcje</string> - <string name="commercial">Reklama</string> - <string name="decimal_seconds">%.2f sekundy</string> - <string name="disabled">Nieaktywne</string> - <string name="download_and_update"><![CDATA[Pobierz i zaktualizuj]]></string> - <string name="directed_by">Wyreżyserowane przez %1$s</string> - <string name="enabled">Aktywne</string> - <string name="error_loading_collection">Błąd podczas ładowania kolekcji %1$s</string> - <string name="external_track">Ścieżka zewnętrzna</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <plurals name="hours"> - <item quantity="one">%d godzina</item> - <item quantity="few">%d godziny</item> - <item quantity="many">%d godzin</item> - <item quantity="other">%d godzin</item> - </plurals> - <string name="image_type">Typ obrazu</string> - <string name="cast_and_crew"><![CDATA[Obsada i ekipa]]></string> - <string name="global_content_scale">Domyślne skalowanie</string> - <string name="direct_play_ass">Używaj libass do napisów ASS</string> - <string name="direct_play_pgs">Odtwarzaj bezpośrednio napisy PGS</string> - <string name="downmix_stereo">Zawsze miksuj w dół do stereo</string> - <string name="app_theme">Motyw interfejsu</string> - <plurals name="downloads"> - <item quantity="one">%s pobieranie</item> - <item quantity="few">%s pobierania</item> - <item quantity="many">%s pobierań</item> - <item quantity="other">%s pobierań</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekunda</item> - <item quantity="few">%d sekundy</item> - <item quantity="many">%d sekund</item> - <item quantity="other">%d sekund</item> - </plurals> - <string name="auto_play_next_delay">Opóźnienie przed odtworzeniem kolejnego odcinka</string> - <string name="auto_play_next">Automatycznie odtwarzaj kolejny odcinek</string> - <string name="combine_continue_next_summary">Stosowane jedynie wobec seriali</string> - <string name="combine_continue_next"><![CDATA[Połącz Kontynuuj oglądanie i Następne]]></string> - <string name="max_homepage_items">Maksimum elementów wiersza na stronie domowej</string> - <string name="nav_drawer_pins_summary">Wybierz domyślne elementy do wyświetlenia, pozostałe zostaną ukryte</string> - <plurals name="items"> - <item quantity="one">%d element</item> - <item quantity="few">%d elementy</item> - <item quantity="many">%d elementów</item> - <item quantity="other">%d elementów</item> - </plurals> - <string name="ac3_supported">Urządzenie wspiera AC3/Dolby Digital</string> - <string name="advanced_ui">Zaawansowany UI</string> - <string name="died">Zmarły(-a)</string> - <string name="play_count">Liczba odtworzeń</string> - <string name="play_from_here">Odtwarzaj od tej pozycji</string> - <string name="playlist">Playlista</string> - <string name="recap">Streszczenie</string> - <string name="record_series">Zapisz serial</string> - <string name="search_and_download"><![CDATA[Znajdź i pobierz]]></string> - <string name="show">Pokaż</string> - <string name="subtitle_download_too_long">Pobieranie napisów trwa zbyt długo, może być konieczne ponowne uruchomienie odtwarzacza</string> - <string name="tv_dvr_schedule">Harmonogram nagrywania</string> - <string name="ui_interface">Interfejs użytkownika</string> - <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_title">Obsada</string> - <string name="none">Brak</string> - <string name="unknown">Nieznany</string> - <string name="success">Powodzenie</string> - <string name="official_rating">Ocena rodzicielska</string> - <string name="extras_title">Dodatki</string> - <plurals name="other_extras"> - <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> - <item quantity="few">Usunięte sceny</item> - <item quantity="many">Usunięte sceny</item> - <item quantity="other">Usunięte sceny</item> - </plurals> - <string name="nav_drawer_switch_on_focus_summary_off">Naciśnij by zmienić stronę</string> - <string name="send_app_logs_summary">Przydatne do debugowania</string> - <string name="send_app_logs">Wyślij logi aplikacji do serwera</string> - <string name="seek_bar_steps">Poziomy przewijania</string> - <string name="played">Obejrzane</string> - <string name="use_server_credentials">Logowanie na serwer</string> - <string name="rewatch_next_up">Umożliwiaj ponowne oglądanie w sekcji następne</string> - <string name="send_crash_reports_summary">Próba wysłania raportu błędów do ostatnio połączonego serwera</string> - <string name="remember_selected_tab">Zapamiętaj wybrane karty</string> - <string name="play_theme_music">Odtwórz muzykę motywu multimediów</string> - <string name="skip_forward_preference">Przewijanie do przodu</string> - <string name="skip_segment_preview">Pomiń wstęp</string> - <string name="skip_segment_recap">Pomiń informacje o poprzednich odcinkach</string> - <string name="nav_drawer_pins">Dopasuj elementy interfejsu nawigacyjnego</string> - <string name="skip_back_preference">Cofanie</string> - <string name="skip_back_on_resume_preference">Cofnij gdy wznawiasz odtwarzanie</string> - <string name="player_backend">Player odtwarzający</string> - <string name="pass_out_protection">Ochrona przed uśpieniem</string> - <string name="ends_at">Kończy się o %1$s</string> - <string name="enter_server_address">Wprowadź adres serwera</string> - <string name="immediate">Natychmiastowo</string> - <string name="no_servers_found">Nie znaleziono serwerów</string> - <string name="only_forced_subtitles">Tylko wymuszone napisy</string> - <string name="voice_search">Wyszukiwanie głosowe</string> - <string name="voice_starting">Uruchamianie</string> - <string name="voice_search_prompt">Mów żeby wyszukać</string> - <string name="processing">Przetwarzanie</string> - <string name="press_back_to_cancel">Wciśnij wstecz aby anulować</string> - <string name="voice_error_audio">Błąd nagrywania dźwięku</string> - <string name="voice_error_client">Błąd rozpoznawania mowy</string> - <string name="voice_error_permissions">Wymagane uprawnienia mikrofonu</string> - <string name="voice_error_network">Błąd sieci</string> - <string name="voice_error_server">Błąd serwera</string> - <string name="retry">Ponów</string> - <string name="video">Wideo</string> - <string name="videos">Widea</string> - <string name="watch_live">Oglądaj na żywo</string> - <string name="quick_connect_success">Urządzenie uwierzytelniono pomyślnie</string> - <string name="password">Hasło</string> - <string name="username">Nazwa użytkownika</string> - <string name="url">URL</string> - <string name="trending">Popularne</string> - <string name="upcoming_movies">Nadchodzące filmy</string> - <string name="upcoming_tv">Nadchodzące seriale</string> - <string name="request_4k">Poproś o 4K</string> - <string name="software_decoding_av1">AV1 dekodowanie programowe</string> - <string name="upgrade_mpv_toast">MPV jest teraz domyślnym odtwarzaczem, z wyjątkiem HDR.\nMożesz to zmienić w ustawieniach.</string> - <string name="hdr_subtitle_style">Styl napisów HDR</string> - <string name="rotate_left">Obróć w lewo</string> - <string name="rotate_right">Obróć w prawo</string> - <string name="zoom_in">Powiększ</string> - <string name="zoom_out">Pomniejsz</string> - <string name="send_media_info_log_to_server">Wyślij dane dziennika mediów do serwera</string> - <string name="no_limit">Bez limitu</string> - <string name="max_days_next_up">Maksymalna ilość dni w \"Do obejrzenia\"</string> - <string name="albums">Albumy</string> - <string name="artists">Artyści</string> - <string name="songs">Utwory</string> - <string name="go_to_artist">Przejdź do artysty</string> - <string name="now_playing">Teraz odtwarzane</string> - <string name="go_to_album">Przejdź do albumu</string> - <string name="add_to_queue">Dodaj do kolejki</string> - <string name="add_row">Dodaj wiersz</string> - <string name="height">Wysokość</string> - <string name="apply_all_rows">Zastosuj do wszystkich wierszy</string> - <string name="customize_home">Personalizacja strony głównej</string> - <string name="load_from_server">Wczytaj profil użytkownika z serwera</string> - <string name="save_to_server">Zapisz do profilu użytkownika na serwerze</string> - <string name="load_from_web_client">Wczytaj z klienta webowego</string> - <string name="voice_error_network_timeout">Przekroczono limit czasu odpowiedzi</string> - <string name="voice_error_no_match">Nie rozpoznano mowy</string> - <string name="general">Ogólne</string> - <string name="container">Kontener</string> - <string name="title">Tytuł</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Poziom</string> - <string name="resolution">Rozdzielczość</string> - <string name="anamorphic">Anamorficzny</string> - <string name="yes">Tak</string> - <string name="no">Nie</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">Pokaż tytuły</string> - <string name="live_tv_repeat">Powtarzaj</string> - <string name="favorite_channels_at_beginning">Pokaż ulubione kanały na początku</string> - <string name="sort_channels_recently_watched">Sortuj kanały według ostatnio oglądanych</string> - <string name="avc">AVC</string> - <string name="language">Język</string> - <string name="layout">Układ</string> - <string name="channels">Kanały</string> - <string name="screensaver">Wygaszacz ekranu</string> - <string name="screensaver_settings">Ustawienia wygaszacza ekranu</string> - <string name="start_screensaver">Pokaż wygaszacz ekranu</string> - <string name="duration">Czas trwania</string> - <string name="photos">Zdjęcia</string> - <string name="include_types">Wybierz typ mediów</string> - <string name="include_types_summary">Dla jakich typów mediów wyświetlać zdjęcia?</string> - <string name="content_scale_fit">Dopasuj</string> - <string name="content_scale_crop">Przytnij</string> - <string name="content_scale_fill">Wypełnij</string> - <string name="content_scale_fill_width">Pełna szerokość</string> - <string name="content_scale_fill_height">Pełna wysokość</string> - <string name="display_preset_default">Domyślny Wolfskin</string> - <string name="display_preset_compact">Kompaktowy Wolfskin</string> - <string name="display_preset_series_thumb">Miniatury seriali</string> - <string name="display_preset_episode_thumbnails">Zdjęcie odcinków</string> - <string name="volume_lowest">Najniższa</string> - <string name="volume_low">Niska</string> - <string name="volume_medium">Średnia</string> - <string name="volume_high">Wysoka</string> - <string name="volume_full">Najwyższa</string> - <string name="purple">Fioletowy</string> - <string name="orange">Pomarańczowy</string> - <string name="bold_blue">Odważny niebieski</string> - <string name="black">Czarny</string> - <string name="skip_ignore">Ignoruj</string> - <string name="skip_automatically">Pomiń automatycznie</string> - <string name="skip_ask">Spytaj o pominięcie</string> - <string name="ffmpeg_fallback">Używaj FFmpeg tylko wtedy, gdy nie istnieje wbudowany dekoder</string> - <string name="ffmpeg_prefer">Używaj FFmpeg zamiast wbudowanych dekoderów</string> - <string name="ffmpeg_never">Nigdy nie używaj dekodera FFmpeg</string> - <string name="next_up_playback_end">Na końcu odtwarzania</string> - <string name="next_up_outro">Podczas napisów końcowych</string> - <string name="white">Biały</string> - <string name="light_gray">Jasno szary</string> - <string name="dark_gray">Ciemno szary</string> - <string name="yellow">Żółty</string> - <string name="cyan">Cyjan</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_shadow">Cień</string> - <string name="subtitle_edge_outline">Ramka</string> - <string name="background_style_wrap">Zawijaj</string> - <string name="background_style_boxed">Opakowane</string> - <string name="prefer_mpv">Preferuj MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Użyj ExoPlayera do odtwarzania 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">Kwadrat (1:1)</string> - <string name="image_type_primary">Plakat</string> - <string name="image_type_thumb">Miniatura</string> - <string name="backdrop_style_dynamic">Zdjęcie z dynamicznym kolorem</string> - <string name="backdrop_style_image">Tylko zdjęcie</string> - <string name="api_key">Klucz API</string> - <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_title">Ulubione %s</string> - <plurals name="days"> - <item quantity="one">%s dzień</item> - <item quantity="few">%s dni</item> - <item quantity="many">%s dni</item> - <item quantity="other">%s dni</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">%s minuta</item> - <item quantity="few">%s minuty</item> - <item quantity="many">%s minut</item> - <item quantity="other">%s minut</item> - </plurals> - <string name="play_trailer">Odtwórz zwiastun</string> - <string name="no_trailers">Brak zwiastunów</string> - <string name="clear_track_choices">Wyczyść wybór utworów</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">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">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> - <string name="seerr_server_added">Serwer Seerr dodany</string> - <string name="quick_connect">Szybkie łączenie</string> - <string name="quick_connect_summary">Autoryzuj inne urządzenie do logowania się na Twoje konto</string> - <string name="quick_connect_code">Wprowadź kod szybkiego łączenia</string> - <string name="quick_connect_code_error">Kod musi mieć 6 cyfr</string> - <string name="image_subtitle_opacity">Przezroczystość napisów na obrazie</string> - <string name="voice_error_busy">Rozpoznawanie głosu w toku</string> - <string name="voice_error_speech_timeout">Nie wykryto mowy</string> - <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 tematyczna</item> - <item quantity="few">Piosenki tematyczne</item> - <item quantity="many">Piosenek tematycznych</item> - <item quantity="other">Piosenek tematycznych</item> - </plurals> - <string name="nav_drawer_switch_on_focus">Przełącz fokus na szufladę nawigacyjną</string> - <string name="playback_overrides">Nadpisywanie odtwarzania</string> - <string name="skip_commercials_behavior">Pomiń reklamy</string> - <string name="skip_intro_behavior">Pomiń czołówkę</string> - <string name="skip_outro_behavior">Pomiń napisy końcowe</string> - <string name="skip_previews_behavior">Pomiń zapowiedzi</string> - <string name="skip_recap_behavior">Pomiń streszczenia</string> - <string name="verbose_logging">Logi szczegółowe</string> - <string name="will_remove_pin">Usunie PIN</string> - <string name="video_range">Zakres wideo</string> - <string name="video_range_type">Typ zakresu wideo</string> - <string name="color_transfer">Transfer kolorów</string> - <string name="color_primaries">Kolory podstawowe</string> - <string name="pixel_format">Format piksela</string> - <string name="ref_frames">Ramki referencyjne</string> - <string name="nal">NAL</string> - <string name="sample_rate">Częstotliwość próbkowania</string> - <string name="color_code_programs">Programy z kodami kolorystycznymi</string> - <string name="subtitle_delay">Opóźnienie napisów</string> - <string name="backdrop_display">Styl tła</string> - <string name="resolution_switching">Zmień rozdzielczość</string> - <string name="local">Lokalny</string> - <string name="brightness">Jasność</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Nasycenie</string> - <string name="hue">Odcień</string> - <string name="red">Czerwony</string> - <string name="green">Zielony</string> - <string name="blue">Niebieski</string> - <string name="blur">Rozmazanie</string> - <string name="save_for_album">Zapisz do albumu</string> - <string name="play_slideshow">Odtwórz pokaz slajdów</string> - <string name="stop_slideshow">Zatrzymaj pokaz slajdów</string> - <string name="slideshow_at_beginning">Na początku</string> - <string name="no_more_images">Nie ma więcej zdjęć</string> - <string name="slideshow_duration">Czas trwania pokazu slajdów</string> - <string name="play_videos_during_slideshow">Odtwarzaj filmy podczas pokazu slajdów</string> - <string name="instant_mix">Mieszanka</string> - <string name="genres_in">Gatunki w %1$s</string> - <string name="recently_released_in">Ostatnio wydane w %1$s</string> - <string name="home_rows">Rzędy na stronie głównej</string> - <string name="use_series">Użyj obrazu serialu</string> - <string name="add_row_for">Dodaj wiersz do: %1$s</string> - <string name="overwrite_server_settings">Nadpisać ustawienia na serwerze?</string> - <string name="overwrite_local_settings">Nadpisać ustawienia lokalne?</string> - <string name="for_episodes">Do odcinków</string> - <string name="suggestions_for">Sugestie dla %1$s</string> - <string name="increase_all_cards_size">Zwiększ rozmiar wszystkich kart</string> - <string name="decrease_all_cards_size">Zmniejsz rozmiar wszystkich kart</string> - <string name="use_thumb_images">Użyj obrazów miniatur</string> - <string name="settings_saved">Ustawienia zapisane</string> - <string name="display_presets">Wyświetl ustawienia wstępne</string> - <string name="display_presets_description">Wbudowane ustawienia wstępne umożliwiające szybką stylizację wszystkich rzędów</string> - <string name="customize_home_summary">Wybierz rzędy i obrazy na stronie głównej</string> - <string name="start_after">Zacznij po</string> - <string name="animate">Animuj</string> - <string name="max_age_rating">Maksymalna ocena wiekowa</string> - <string name="no_max">Bez ograniczeń</string> - <string name="for_all_ages">Dla wszystkich grup wiekowych</string> - <string name="up_to_age">Do %s lat</string> - <string name="enter_url_api_key"><![CDATA[Wprowadź adres URL i klucz API]]></string> - <string name="search_and_download_subtitles"><![CDATA[Wyszukaj i pobierz napisy]]></string> - <string name="no_subtitles_found">Nie znaleziono napisów na serwerze</string> - <string name="series_continueing">Teraz</string> - <string name="in_app_screensaver">Użyj wygaszacza ekranu z aplikacji</string> - <string name="delete_item">Czy na pewno chcesz usunąć ten element?</string> - <string name="show_media_management">Pokaż opcje zarządzania multimediami</string> - <string name="search_for">Wyszukaj %s</string> - <string name="actor">Aktor</string> - <string name="composer">Kompozytor</string> - <string name="writer">Scenarzysta</string> - <string name="guest_star">Gwiazda gościnna</string> - <string name="producer">Producent</string> - <string name="conductor">Kierownik</string> - <string name="lyricist">Autor tekstów</string> - <string name="arranger">Aranżer</string> - <string name="engineer">Inżynier dźwięku</string> - <string name="mixer">Mikser</string> - <string name="creator">Twórca</string> - <string name="artist">Artysta</string> - <string name="album_artist">Artysta albumu</string> - <string name="album">Album</string> - <string name="popular_songs">Popularne piosenki</string> - <string name="play_next">Odtwarzaj dalej</string> - <string name="music_videos">Teledyski</string> - <string name="lyrics">Tekst</string> - <string name="hide_lyrics">Ukryj tekst</string> - <string name="show_lyrics">Pokaż tekst</string> - <string name="song_has_lyrics">Piosenka ma tekst</string> - <string name="remove_from_queue">Usuń z kolejki</string> - <string name="show_album_cover">Pokaż okładkę albumu</string> - <string name="show_visualizer">Pokaż wizualizator</string> - <string name="show_backdrop">Pokaż tło</string> - <string name="play_as_type">Odtwórz jako?</string> - <string name="visualizer_rationale">Aby móc wizualizować aktualnie odtwarzany dźwięk, wymagane jest pozwolenie na nagrywanie dźwięku.</string> - <string name="continue_string">Kontynuuj</string> - <string name="select_all">Wybierz wszystkie</string> - <string name="separate_types">Oddzielne typy</string> - <string name="prefer_logos">Wyświetlanie logo zamiast tytułu</string> - <string name="updated_toast">Wolfskin zaktualizowany do %s</string> - <string name="remove_continue_watching">Usuń z Kontynuuj oglądanie</string> - <string name="remove_next_up">Usuń serial z Następnie</string> - <string name="view_removed_next_up">Zobacz serię usuniętą z Następnie</string> - <string name="view_more">Zobacz więcej</string> - <string name="discover_tv">Odkryj programy TV</string> - <string name="studios_in">Studio w %1$s</string> - <string name="voice_error_unknown">Nieznany błąd</string> - <string name="media_information">Informacje o mediach</string> - <string name="aired_episode_order">Kolejność emisji odcinków</string> - <string name="runtime_sort">Czas trwania</string> - <plurals name="clips"> - <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ó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">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ó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> - <string name="refresh_rate_switching">Przełączenie częstotliwości odświeżania</string> - <string name="automatic">Automatycznie</string> - <string name="bit_depth">Głębia bitowa</string> - <string name="color_space">Przestrzeń kolorów</string> - <string name="interlaced">Przeplatany</string> - <string name="framerate">Liczba klatek</string> - <string name="discover_movies">Odkrywaj filmy</string> - <string name="next_up_never">Nigdy</string> - <string name="external_player">Zewnętrzny odtwarzacz</string> - <string name="last_played">Ostatnio odtwarzane</string> - <string name="system_default">Domyślny systemowy</string> - <string name="ass_subtitle_mode_libass">Bezpośrednie odtwarzanie z libass</string> - <string name="ass_subtitle_mode_exoplayer">Bezpośrednie odtwarzanie z wbudowanym ExoPlayer</string> - <string name="ass_subtitle_mode_transcode">Wypalaj napisy/transkoduj źródło na serwerze</string> - <string name="ass_subtitle_playback">Odtwarzanie napisów SSA/ASS</string> - <string name="cinema_mode">Tryb kinowy</string> - <string name="show_more">Pokaż %1$s więcej</string> - <string name="skip_behavior">Zachowanie pomijania</string> - <string name="skip_behavior_summary">Dla wstępu, napisów końcowych itp.</string> - <string name="transcoding">Transkodowanie</string> - <string name="play_with">Odtwórz z</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="combined_search_results">Połączone wyniki wyszukiwania</string> - <string name="combined_search_results_on">Pokaż wszystkie wyniki jako jedna lista</string> - <string name="combined_search_results_off">Podziel wyniki wyszukiwania na kategorie</string> - <string name="results">Wyniki</string> - <string name="require_login">Wymagaj logowania do profilu</string> - <string name="show_voice_search_button">Pokaż przycisk wyszukiwania głosowego</string> - <string name="visible_ui">Widoczne</string> - <string name="hidden_ui">Ukryte</string> - <string name="display_toggles_title">Pokaż oceny</string> - <string name="display_toggles_summary">Dostosuj co chcesz wyświetlić</string> - <string name="profile_protection">Zabezpieczenie profilu</string> - <string name="continue_watching_click_behavior">Zachowanie kliknięcia Kontynuuj oglądanie/Następnie</string> - <string name="continue_watching_click_summary_on">Odtwórz po kliknięciu</string> - <string name="continue_watching_click_summary_off">Pokaż szczegóły po kliknięciu</string> - <string name="stars">Gwiazdki</string> - <string name="tomatoes">Pomidor</string> - <string name="view_options_type_list">Lista</string> - <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> - <string name="disable_audio_offload">Wyłącz sprzętowe wsparcie dźwięku</string> - <string name="disable_audio_offload_summary">Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080p</string> - <string name="resolution_720">720p</string> - <string name="resolution_576">576p</string> - <string name="resolution_480">480p</string> - <string name="resolution_360">360p</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d odcinek</item> - <item quantity="few">%1$d odcinki</item> - <item quantity="many">%1$d odcinków</item> - <item quantity="other">%1$d odcinków</item> - </plurals> - <string name="max_resolution">Maksymalna rozdzielczość przed transkodowaniem</string> - <string name="prefer_dovi_over_hdr10_plus">Preferuj Dolby Vision nad HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+</string> - <string name="top_rated">Najlepiej oceniane</string> - <string name="hdr_formats">Formaty HDR</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision P5</string> - <string name="dovi_profile_7">Dolby Vision P7</string> - <string name="dovi_profile_8">Dolby Vision P8</string> - <string name="force_enable">Wymuś włączenie</string> - <string name="force_disable">Wymuś wyłączenie</string> - <string name="server_version_not_supported_future">Wersja serwera wkrótce przestanie być wspierana</string> - <string name="server_version_required">Wymagana wersja serwera: %1$s</string> - <string name="chosen_by">Wybrane przez %1$s</string> - <string name="user">Użytkownik</string> - <string name="series">Seriale</string> - <string name="load_from_server_plugin">Załaduj domyślne z serwera</string> - <string name="save_to_local">Zapisuj tylko na tym urządzeniu</string> - <string name="home_settings_source_unset">Domyślnie</string> - <string name="home_settings_source_local">Zależne od urządzenia</string> - <string name="home_settings_source_server_profile">Profil użytkownika na serwerze</string> - <string name="home_settings_source_plugin">Domyślnie z serwera</string> - <string name="home_settings_source_web">Użytkownik klienta webowego</string> - <string name="convert_dovi_profile_7">Konwertuj Dolby Vision 7 do 8.1</string> - <string name="convert_dovi_profile_7_summary">W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie</string> - <string name="artist_source">Źródło artysty</string> - <string name="managed_by_server">Zarządzane przez serwer</string> - <string name="error">Błąd</string> -</resources> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml deleted file mode 100644 index af1e0d9f..00000000 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,720 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Sobre</string> - <string name="active_recordings">Gravações ativas</string> - <string name="add_favorite">Favoritar</string> - <string name="add_server">Adicionar Servidor</string> - <string name="add_user">Adicionar Usuário</string> - <string name="audio">Áudio</string> - <string name="birthplace">Local de nascimento</string> - <string name="bitrate">Taxa de bits</string> - <string name="born">Nascido</string> - <string name="cancel_recording">Cancelar gravação</string> - <string name="cancel_series_recording">Cancelar gravação da série</string> - <string name="cancel">Cancelar</string> - <string name="chapters">Capítulos</string> - <string name="choose_stream">Escolha %1$s</string> - <string name="clear_image_cache">Limpar cache de imagens</string> - <string name="collection">Coleção</string> - <string name="collections">Coleções</string> - <string name="commercial">Comerciais</string> - <string name="community_rating">Avaliação da comunidade</string> - <string name="compose_error_message_title">Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor.</string> - <string name="confirm">Confirmar</string> - <string name="continue_watching">Continue assistindo</string> - <string name="critic_rating">Avaliação dos críticos</string> - <string name="decimal_seconds">%.2f segundos</string> - <string name="default_track">Padrão</string> - <string name="delete">Remover</string> - <string name="died">Falecido</string> - <string name="directed_by">Dirigido por %1$s</string> - <string name="director">Diretor</string> - <string name="disabled">Desabilitado</string> - <string name="discovered_servers">Servidores encontrados</string> - <string name="downloading">Baixando…</string> - <string name="enabled">Habilitado</string> - <string name="ends_at">Termina às %1$s</string> - <string name="enter_server_url">Insira o IP ou URL do Servidor</string> - <string name="enter_server_address">Insira o endereço do servidor</string> - <string name="episodes">Episódios</string> - <string name="error_loading_collection">Erro ao carregar a coleção %1$s</string> - <string name="external_track">Externo</string> - <string name="favorites">Favoritos</string> - <string name="file_size">Tamanho</string> - <string name="forced_track">Forçado</string> - <string name="genres">Gêneros</string> - <string name="go_to_series">Ir para séries</string> - <string name="go_to">Ir Para</string> - <string name="hide_controller_timeout">Ocultar controles de reprodução</string> - <string name="hide_debug_info">Ocultar informações de depuração</string> - <string name="hide">Ocultar</string> - <string name="home">Início</string> - <string name="immediate">Imediato</string> - <string name="intro">Introdução</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Biblioteca</string> - <string name="license_info">Informação de licenças</string> - <string name="live_tv">TV ao Vivo</string> - <string name="loading">Carregando…</string> - <string name="mark_entire_series_as_played">Marcar série inteira como reproduzida?</string> - <string name="mark_entire_series_as_unplayed">Marcar séria inteira como não reproduzida?</string> - <string name="mark_unwatched">Marcar como não assistida</string> - <string name="mark_watched">Marcar como assistida</string> - <string name="max_bitrate">Taxa de bits máxima</string> - <string name="more_like_this">Mais como isso</string> - <string name="more">Mais</string> - <plurals name="movies"> - <item quantity="one">Filme</item> - <item quantity="many">Filmes</item> - <item quantity="other">Filmes</item> - </plurals> - <string name="name">Nome</string> - <string name="next_up">A Seguir</string> - <string name="no_data">Sem dados</string> - <string name="no_results">Sem resultados</string> - <string name="no_servers_found">Nenhum servidor encontrado</string> - <string name="no_scheduled_recordings">Nenhuma gravação agendada</string> - <string name="no_update_available">Nenhuma atualização disponível</string> - <string name="none">Nenhum(a)</string> - <string name="only_forced_subtitles">Apenas Legendas Forçadas</string> - <string name="outro">Finalização</string> - <string name="path">Caminho</string> - <plurals name="people"> - <item quantity="one">Pessoa</item> - <item quantity="many">Pessoas</item> - <item quantity="other">Pessoas</item> - </plurals> - <string name="play_count">Contagem de Reproduções</string> - <string name="play_from_here">Reproduzir a partir daqui</string> - <string name="play">Reproduzir</string> - <string name="playback_debug_info">Mostrar informações de depuração da reprodução</string> - <string name="playback_speed">Velocidade de Reprodução</string> - <string name="playback">Reprodução</string> - <string name="playlist">Lista de reprodução</string> - <string name="playlists">Listas de reprodução</string> - <string name="preview">Prévia</string> - <string name="profile_specific_settings">Configurações do Perfil de Usuário</string> - <string name="queue">Fila</string> - <string name="recap">Recapitulação</string> - <string name="recently_added_in">Recentemente adicionado em %1$s</string> - <string name="recently_added">Recentemente adicionado</string> - <string name="recently_recorded">Recentemente Gravado</string> - <string name="recently_released">Recentemente Publicado</string> - <string name="recommended">Recomendado</string> - <string name="record_program">Gravar Programa</string> - <string name="record_series">Gravar Série</string> - <string name="remove_favorite">Desfavoritar</string> - <string name="restart">Reiniciar</string> - <string name="resume">Continuar</string> - <string name="save">Salvar</string> - <string name="search">Procurar</string> - <string name="searching">Procurando…</string> - <string name="voice_search">Pesquisa por voz</string> - <string name="voice_starting">Iniciando</string> - <string name="voice_search_prompt">Fale para pesquisar</string> - <string name="processing">Processando</string> - <string name="press_back_to_cancel">Aperte voltar para cancelar</string> - <string name="voice_error_audio">Erro de gravação de áudio</string> - <string name="voice_error_client">Erro de reconhecimento de voz</string> - <string name="voice_error_permissions">Permissão de microfone necessária</string> - <string name="voice_error_network">Erro de rede</string> - <string name="voice_error_network_timeout">Tempo limite de rede esgotado</string> - <string name="voice_error_no_match">Nenhuma fala reconhecida</string> - <string name="voice_error_busy">Reconhecimento de voz ocupado</string> - <string name="voice_error_server">Erro de servidor</string> - <string name="voice_error_speech_timeout">Nenhuma fala detectada</string> - <string name="voice_error_unknown">Erro desconhecido</string> - <string name="voice_error_start_failed">Falha no início do reconhecimento</string> - <string name="voice_error_timeout">Tempo limite do reconhecimento de voz esgotado</string> - <string name="retry">Tentar novamente</string> - <string name="select_server">Selecionar Servidor</string> - <string name="select_user">Selecionar Usuário</string> - <string name="show_debug_info">Mostrar informações de depuração</string> - <string name="show_next_up_when">Mostrar próximo</string> - <string name="show">Mostrar</string> - <string name="shuffle">Aleatório</string> - <string name="skip">Pular</string> - <string name="sort_by_date_added">Data de Adição</string> - <string name="sort_by_date_episode_added">Data de Adição do Episódio</string> - <string name="sort_by_date_played">Data de Reprodução</string> - <string name="sort_by_date_released">Data de Lançamento</string> - <string name="sort_by_name">Nome</string> - <string name="sort_by_random">Aleatório</string> - <string name="studios">Estúdios</string> - <string name="submit">Enviar</string> - <string name="subtitle_download_too_long">O download está demorando muito, talvez você tenha que reiniciar a reprodução</string> - <string name="subtitle">Legenda</string> - <string name="subtitles">Legendas</string> - <string name="suggestions">Sugestões</string> - <string name="switch_servers">Trocar de servidores</string> - <string name="switch_user">Trocar</string> - <string name="top_unwatched">Não assistidos com melhor avaliação</string> - <string name="tv_dvr_schedule">Agenda do DVR</string> - <string name="tv_guide">Guia</string> - <string name="tv_season">Temporada</string> - <string name="tv_seasons">Temporadas</string> - <plurals name="tv_shows"> - <item quantity="one">Programa de TV</item> - <item quantity="many">Programas de TV</item> - <item quantity="other">Programas de TV</item> - </plurals> - <string name="ui_interface">Interface</string> - <string name="unknown">Desconhecido</string> - <string name="updates">Atualizações</string> - <string name="version">Versão</string> - <string name="video_scale">Escala de Vídeo</string> - <string name="video">Vídeo</string> - <string name="videos">Vídeos</string> - <string name="watch_live">Assista ao vivo</string> - <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> - <string name="aired_episode_order">Ordem de Lançamento do Episódio</string> - <string name="create_playlist">Criar nova lista de reprodução</string> - <string name="add_to_playlist">Adicionar a lista de reprodução</string> - <string name="one_click_pause">Pausar com um clique</string> - <string name="one_click_pause_summary_on">Aperte o centro do controle direcional para pausar/reproduzir</string> - <string name="italic_font">Colocar a fonte em itálico</string> - <string name="font">Fonte</string> - <string name="background">Plano de fundo</string> - <string name="success">Sucesso</string> - <string name="official_rating">Classificação Parental</string> - <string name="runtime_sort">Duração</string> - <string name="extras_title">Extras</string> - <plurals name="other_extras"> - <item quantity="one">Outro</item> - <item quantity="many">Outros</item> - <item quantity="other">Outros</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Por Trás das Cenas</item> - <item quantity="many">Por Trás das Cenas</item> - <item quantity="other">Por Trás das Cenas</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Música Tema</item> - <item quantity="many">Músicas Tema</item> - <item quantity="other">Músicas Tema</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Vídeo Tema</item> - <item quantity="many">Vídeos Tema</item> - <item quantity="other">Vídeos Tema</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Clipe</item> - <item quantity="many">Clipes</item> - <item quantity="other">Clipes</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Cena Deletada</item> - <item quantity="many">Cenas Deletadas</item> - <item quantity="other">Cenas Deletadas</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Entrevista</item> - <item quantity="many">Entrevistas</item> - <item quantity="other">Entrevistas</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Cena</item> - <item quantity="many">Cenas</item> - <item quantity="other">Cenas</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Amostra</item> - <item quantity="many">Amostras</item> - <item quantity="other">Amostras</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Curta</item> - <item quantity="many">Curtas</item> - <item quantity="other">Curtas</item> - </plurals> - <string name="favorite_items_title">Favoritar %s</string> - <plurals name="downloads"> - <item quantity="one">%s download</item> - <item quantity="many">%s downloads</item> - <item quantity="other">%s downloads</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d hora</item> - <item quantity="many">%d horas</item> - <item quantity="other">%d horas</item> - </plurals> - <plurals name="days"> - <item quantity="one">%s dia</item> - <item quantity="many">%s dias</item> - <item quantity="other">%s dias</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d item</item> - <item quantity="many">%d itens</item> - <item quantity="other">%d itens</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d segundo</item> - <item quantity="many">%d segundos</item> - <item quantity="other">%d segundos</item> - </plurals> - <string name="ac3_supported">Dispositivo suporta AC3/Dolby Digital</string> - <string name="advanced_settings">Configurações Avançadas</string> - <string name="advanced_ui">IU Avançada</string> - <string name="app_theme">Tema do Aplicativo</string> - <string name="auto_check_for_updates">Checar atualizações automaticamente</string> - <string name="auto_play_next_delay">Atraso antes da próxima reprodução</string> - <string name="auto_play_next">Reproduzir próximo automaticamente</string> - <string name="check_for_updates">Checar atualizações</string> - <string name="combine_continue_next_summary">Aplicar apenas a Séries de TV</string> - <string name="direct_play_ass">Usar libass para reproduzir legendas ASS</string> - <string name="direct_play_pgs">Reproduzir legendas PGS diretamente</string> - <string name="downmix_stereo">Sempre mixar para estéreo</string> - <string name="ffmpeg_extension_pref">Usar módulo de decodificação do FFmpeg</string> - <string name="global_content_scale">Escala padrão do conteúdo</string> - <string name="install_update">Instalar atualização</string> - <string name="installed_version">Versão instalada</string> - <string name="max_homepage_items">Máximo de itens nas linhas da página inicial</string> - <string name="nav_drawer_pins_summary">Escolher itens exibidos por padrão, os outros serão ocultados</string> - <string name="nav_drawer_pins">Personalizar os Itens da Gaveta de Navegação</string> - <string name="nav_drawer_switch_on_focus_summary_off">Clicar para trocar de páginas</string> - <string name="nav_drawer_switch_on_focus">Trocar a página de navegação no foco</string> - <string name="pass_out_protection">Proteção Contra Desmaios</string> - <string name="play_theme_music">Tocar música tema</string> - <string name="playback_overrides">Sobreposições de reprodução</string> - <string name="remember_selected_tab">Lembrar abas selecionadas</string> - <string name="rewatch_next_up">Habilitar reassistir em a seguir</string> - <string name="seek_bar_steps">Passos da barra de busca</string> - <string name="send_app_logs_summary">Útil para depuração</string> - <string name="send_app_logs">Enviar os registros do aplicativo para o servidor atual</string> - <string name="send_crash_reports_summary">Tentativa de envio para o último servidor conectado</string> - <string name="send_crash_reports">Enviar Relatórios de Falhas</string> - <string name="settings">Configurações</string> - <string name="skip_commercials_behavior">Comportamento de pular os comerciais</string> - <string name="skip_intro_behavior">Comportamento de pular as introduções</string> - <string name="skip_outro_behavior">Comportamento de pular a finalização</string> - <string name="skip_previews_behavior">Comportamento de pular as prévias</string> - <string name="skip_recap_behavior">Comportamento de pular as recapitulações</string> - <string name="update_available">Atualização disponível</string> - <string name="update_url_summary">URL usada para verificação de atualizações</string> - <string name="update_url">URL de atualização</string> - <string name="font_size">Tamanho da fonte</string> - <string name="font_color">Cor da fonte</string> - <string name="font_opacity">Opacidade da fonte</string> - <string name="edge_style">Estilo da borda</string> - <string name="edge_color">Cor da borda</string> - <string name="background_opacity">Opacidade do fundo</string> - <string name="background_style">Estilo do fundo</string> - <string name="subtitle_style">Estilo da legenda</string> - <string name="background_color">Cor do fundo</string> - <string name="reset">Redefinir</string> - <string name="bold_font">Fonte em negrito</string> - <string name="player_backend">Backend de reprodução</string> - <string name="mpv_hardware_decoding">MPV: Usar decodificação baseada em hardware</string> - <string name="disable_if_crash">Desabilite se encontrar falhas</string> - <string name="mpv_options">Opções do MPV</string> - <string name="exoplayer_options">Opções do ExPlayer</string> - <string name="skip_segment_unknown">Pular Segmento</string> - <string name="skip_segment_commercial">Pular Anúncios</string> - <string name="skip_segment_preview">Pular Prévia</string> - <string name="skip_segment_recap">Pular Recapitulação</string> - <string name="skip_segment_outro">Pular Finalização</string> - <string name="skip_segment_intro">Pular Introdução</string> - <string name="played">Reproduzido</string> - <string name="filter">Filtro</string> - <string name="year">Ano</string> - <string name="decade">Década</string> - <string name="remove">Remover</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> - <string name="mpv_conf">Editar mpv.conf</string> - <string name="discard_change">Descartar mudanças?</string> - <string name="subtitle_margin">Margem</string> - <string name="verbose_logging">Registro extenso</string> - <string name="enter_pin">Insira o PIN</string> - <string name="sign_in_auto">Entrar automaticamente</string> - <string name="use_server_credentials">Autenticar via servidor</string> - <string name="press_enter_to_confirm">Aperte o botão central para confirmar</string> - <string name="require_pin_code">Exigir PIN para o perfil</string> - <string name="confirm_pin">Confirmar o PIN</string> - <string name="incorrect">Incorreto</string> - <string name="pin_too_short">PIN deve ter 4 ou mais dígitos</string> - <string name="will_remove_pin">Irá remover o PIN</string> - <string name="image_cache_size">Tamanho do cache de imagens no disco (MB)</string> - <string name="view_options">Ver opções</string> - <string name="columns">Colunas</string> - <string name="spacing">Espaçamento</string> - <string name="aspect_ratio">Proporção da Tela</string> - <string name="show_details">Mostrar detalhes</string> - <string name="image_type">Tipo de imagem</string> - <string name="guest_stars">Estrelas convidadas</string> - <string name="edge_size">Tamanho da borda</string> - <string name="refresh_rate_switching">Troca de taxa de atualização</string> - <string name="automatic">Automático</string> - <string name="username_or_password">Usar nome de usuário / senha</string> - <string name="login">Autenticação</string> - <string name="general">Geral</string> - <string name="container">Contêiner</string> - <string name="title">Título</string> - <string name="codec">Codec</string> - <string name="profile">Perfil</string> - <string name="level">Nível</string> - <string name="resolution">Resolução</string> - <string name="anamorphic">Anamórfico</string> - <string name="interlaced">Entrelaçado</string> - <string name="framerate">Taxa de atualização</string> - <string name="bit_depth">Profundidade de bits</string> - <string name="video_range">Alcance de vídeo</string> - <string name="video_range_type">Tipo de alcance de vídeo</string> - <string name="color_space">Espaço de cores</string> - <string name="color_transfer">Transferência de cores</string> - <string name="color_primaries">Primárias de cores</string> - <string name="pixel_format">Formato de pixel</string> - <string name="ref_frames">Quadros de referência</string> - <string name="language">Idioma</string> - <string name="layout">Layout</string> - <string name="channels">Canais</string> - <string name="sample_rate">Taxa de amostra</string> - <string name="yes">Sim</string> - <string name="no">Não</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Mostrar títulos</string> - <string name="live_tv_repeat">Repetir</string> - <string name="favorite_channels_at_beginning">Mostrar canais favoritos primeiro</string> - <string name="sort_channels_recently_watched">Ordenar canais por recentemente assistidos</string> - <string name="color_code_programs">Codificar programas por cor</string> - <string name="subtitle_delay">Atraso na legenda</string> - <string name="backdrop_display">Estilo de fundo</string> - <string name="resolution_switching">Troca de resolução</string> - <string name="local">Local</string> - <string name="play_trailer">Reproduzir trailer</string> - <string name="no_trailers">Nenhum trailer</string> - <string name="clear_track_choices">Limpar escolhas de faixas</string> - <string name="dts_x">DTS:X</string> - <string name="dts_hd">DTS:HD</string> - <string name="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">Reprodução direta de Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">Ignorar checagens de compatibilidade de dispositivo</string> - <string name="discover">Descobrir</string> - <string name="request">Solicitar</string> - <string name="pending">Pendente</string> - <string name="seerr_integration">Integração com o Seerr</string> - <string name="remove_seerr_server">Remover Servidor Seerr</string> - <string name="seerr_server_added">Servidor Seerr adicionado</string> - <string name="quick_connect">Conexão Rápida</string> - <string name="quick_connect_summary">Autorizar outro dispositivo a se autenticar com sua conta</string> - <string name="quick_connect_code">Insira o código de conexão rápida</string> - <string name="quick_connect_code_error">O código deve ter 6 dígitos</string> - <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> - <string name="password">Senha</string> - <string name="username">Nome de usuário</string> - <string name="url">URL</string> - <string name="trending">Tendências</string> - <string name="upcoming_movies">Filmes Lançados em Breve</string> - <string name="upcoming_tv">Programas de TV Lançados em Breve</string> - <string name="request_4k">Solicitar em 4K</string> - <string name="software_decoding_av1">Decodificação de AV1 via software</string> - <string name="upgrade_mpv_toast">MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações.</string> - <string name="hdr_subtitle_style">Estilo da legenda HDR</string> - <string name="image_subtitle_opacity">Opacidade da legenda de imagem</string> - <string name="brightness">Brilho</string> - <string name="contrast">Contraste</string> - <string name="saturation">Saturação</string> - <string name="hue">Matiz</string> - <string name="red">Vermelho</string> - <string name="green">Verde</string> - <string name="blue">Azul</string> - <string name="blur">Desfoque</string> - <string name="play_slideshow">Reproduzir apresentação de slides</string> - <string name="stop_slideshow">Interromper apresentação de slides</string> - <string name="slideshow_at_beginning">No começo</string> - <string name="no_more_images">Sem mais fotos</string> - <string name="rotate_left">Girar a esquerda</string> - <string name="rotate_right">Girar a direita</string> - <string name="zoom_in">Mais zoom</string> - <string name="zoom_out">Menos zoom</string> - <string name="slideshow_duration">Duração da reprodução de slides</string> - <string name="play_videos_during_slideshow">Reproduzir vídeos ao longo da apresentação de slides</string> - <string name="send_media_info_log_to_server">Enviar registro de informações da mídia ao servidor</string> - <string name="no_limit">Sem limite</string> - <string name="max_days_next_up">Máximo de dias no A Seguir</string> - <string name="add_row">Adicionar linha</string> - <string name="genres_in">Gêneros em %1$s</string> - <string name="recently_released_in">Recentemente lançado em %1$s</string> - <string name="height">Altura</string> - <string name="apply_all_rows">Aplicar a todas as linhas</string> - <string name="customize_home">Personalizar página inicial</string> - <string name="skip_back_on_resume_preference">Pular para trás ao resumir a reprodução</string> - <string name="skip_back_preference">Pular para trás</string> - <string name="skip_forward_preference">Pular para frente</string> - <string name="nal">NAL</string> - <string name="avc">AVC</string> - <string name="save_for_album">Salvar para álbum</string> - <string name="home_rows">Linhas iniciais</string> - <string name="load_from_server">Carregar a partir do perfil de usuário no servidor</string> - <string name="save_to_server">Salvar para o perfil de usuário no servidor</string> - <string name="load_from_web_client">Carregar a partir do cliente web</string> - <string name="use_series">Usar imagem da série</string> - <string name="add_row_for">Adicionar linha para %1$s</string> - <string name="overwrite_server_settings">Sobrescrever configurações no servidor?</string> - <string name="overwrite_local_settings">Sobrescrever configurações locais?</string> - <string name="for_episodes">Para episódios</string> - <string name="suggestions_for">Sugestões para %1$s</string> - <string name="increase_all_cards_size">Aumentar tamanho para todos os cartões</string> - <string name="decrease_all_cards_size">Diminuir tamanho para todos os cartões</string> - <string name="use_thumb_images">Usar imagens de prévia</string> - <string name="settings_saved">Configurações salvas</string> - <string name="display_presets">Predefinições de exibição</string> - <string name="display_presets_description">Predefinições embutidas para estilizar rapidamente todas as linhas</string> - <string name="customize_home_summary">Escolher linhas e imagens na página inicial</string> - <string name="start_after">Iniciar após</string> - <string name="animate">Animar</string> - <string name="max_age_rating">Classificação etária máxima</string> - <string name="no_max">Sem máximo</string> - <string name="for_all_ages">Para todas as idades</string> - <string name="up_to_age">Até a idade %s</string> - <string name="screensaver">Protetor de tela</string> - <string name="screensaver_settings">Configurações do protetor de tela</string> - <string name="start_screensaver">Iniciar o protetor de tela</string> - <string name="duration">Duração</string> - <string name="photos">Fotos</string> - <string name="include_types">Incluir tipos</string> - <string name="include_types_summary">Para quais tipos de itens mostrar imagens</string> - <string name="content_scale_fit">Ajustar</string> - <string name="content_scale_crop">Cortar</string> - <string name="content_scale_fill">Preencher</string> - <string name="content_scale_fill_width">Preencher largura</string> - <string name="content_scale_fill_height">Preencher altura</string> - <string name="display_preset_default">Padrão do Wolfskin</string> - <string name="display_preset_compact">Wolfskin Compacto</string> - <string name="display_preset_series_thumb">Prévia das imagens das séries</string> - <string name="display_preset_episode_thumbnails">Prévias das imagens do episódio</string> - <string name="volume_lowest">Mais baixo</string> - <string name="volume_low">Baixo</string> - <string name="volume_medium">Médio</string> - <string name="volume_high">Alto</string> - <string name="volume_full">Máximo</string> - <string name="purple">Roxo</string> - <string name="orange">Laranja</string> - <string name="bold_blue">Azul intenso</string> - <string name="black">Preto</string> - <string name="skip_ignore">Ignorar</string> - <string name="skip_automatically">Pular automaticamente</string> - <string name="skip_ask">Perguntar se pular</string> - <string name="ffmpeg_fallback">Apenas usar FFmpeg se não existir nenhum decodificador embarcado</string> - <string name="ffmpeg_prefer">Preferir usar FFmpeg aos decodificadores embarcados</string> - <string name="ffmpeg_never">Nunca usar decodificadores FFmpeg</string> - <string name="next_up_playback_end">Ao fim da reprodução</string> - <string name="next_up_outro">Durante os créditos/finalização</string> - <string name="white">Branco</string> - <string name="light_gray">Cinza claro</string> - <string name="dark_gray">Cinza escuro</string> - <string name="yellow">Amarelo</string> - <string name="cyan">Ciano</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contorno</string> - <string name="subtitle_edge_shadow">Sombra</string> - <string name="background_style_wrap">Envolver</string> - <string name="background_style_boxed">Encaixotar</string> - <string name="prefer_mpv">Preferir MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</string> - <string name="aspect_ratios_poster">Pôster (2:3)</string> - <string name="aspect_ratios_16_9">16:9</string> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Quadrado (1:1)</string> - <string name="image_type_primary">Primária</string> - <string name="image_type_thumb">Prévia</string> - <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> - <string name="backdrop_style_image">Apenas imagem</string> - <string name="api_key">Chave de API</string> - <string name="seerr_login">Autenticar no servidor Seerr</string> - <string name="seerr_jellyfin_user">Usuário Jellyfin</string> - <string name="seerr_local_user">Usuário local</string> - <string name="no_subtitles_found">Nenhuma legenda remota encontrada</string> - <string name="series_continueing">Presente</string> - <string name="in_app_screensaver">Usar protetor de tela do aplicativo</string> - <string name="delete_item">Tem certeza que quer apagar este item?</string> - <string name="show_media_management">Mostrar opções de gerenciamento de mídia</string> - <string name="actor">Ator</string> - <string name="composer">Compositor</string> - <string name="writer">Escritor</string> - <string name="guest_star">Estrela convidada</string> - <string name="producer">Produtor</string> - <string name="conductor">Regente</string> - <string name="lyricist">Letrista</string> - <string name="arranger">Arranjador</string> - <string name="engineer">Engenheiro</string> - <string name="creator">Criador</string> - <string name="artist">Artista</string> - <string name="search_for">Buscar %s</string> - <string name="mixer">Mixagem</string> - <plurals name="featurettes"> - <item quantity="one">Bastidores</item> - <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">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> - <string name="go_to_artist">Ir para artista</string> - <string name="now_playing">Reproduzindo agora</string> - <string name="instant_mix">Mix instantâneo</string> - <string name="go_to_album">Ir para álbum</string> - <string name="add_to_queue">Adicionar à fila</string> - <string name="next_up_never">Nunca</string> - <string name="external_player">Reprodutor externo</string> - <string name="album_artist">Artista do álbum</string> - <string name="album">Álbum</string> - <string name="popular_songs">Músicas populares</string> - <string name="play_next">Reproduzir a seguir</string> - <string name="music_videos">Vídeoclipes</string> - <string name="lyrics">Letras</string> - <string name="hide_lyrics">Ocultar letras</string> - <string name="show_lyrics">Mostrar letras</string> - <string name="song_has_lyrics">Música contém letras</string> - <string name="remove_from_queue">Remover da fila</string> - <string name="show_album_cover">Mostrar capa do álbum</string> - <string name="show_visualizer">Mostrar visualizador</string> - <string name="show_backdrop">Mostrar fundo</string> - <string name="play_as_type">Reproduzir como?</string> - <string name="visualizer_rationale">Visualizar o áudio atualmente reproduzido requer as permissões de gravação de áudio.</string> - <string name="continue_string">Continuar</string> - <string name="select_all">Selecionar todos</string> - <string name="separate_types">Tipos separados</string> - <string name="prefer_logos">Preferir a exibição de logos para títulos</string> - <string name="updated_toast">Wolfskin atualizado para %s</string> - <string name="remove_continue_watching">Remover de continuar assistindo</string> - <string name="remove_next_up">Remover série de a seguir</string> - <string name="view_removed_next_up">Ver séries removidas de a seguir</string> - <string name="view_more">Ver mais</string> - <string name="discover_tv">Descobrir programas de TV</string> - <string name="discover_movies">Descobrir Filmes</string> - <string name="studios_in">Estúdios em %1$s</string> - <string name="last_played">Reproduzido por último</string> - <string name="system_default">Padrão do sistema</string> - <string name="ass_subtitle_mode_libass">Reprodução direta com libass</string> - <string name="ass_subtitle_mode_exoplayer">Reprodução direta com ExoPlayer embarcado</string> - <string name="ass_subtitle_mode_transcode">Gravar/transcodificar no servidor</string> - <string name="ass_subtitle_playback">Reprodução de legenda SSA/ASS</string> - <string name="cinema_mode">Modo cinema</string> - <string name="show_more">Mostrar mais %1$s</string> - <string name="skip_behavior">Comportamento de pulo</string> - <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 f65e53af..045e125f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,784 +1,3 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">Sobre</string> - <string name="add_favorite">Favorito</string> - <string name="add_server">Adicionar Servidor</string> - <string name="add_user">Adicionar Utilizador</string> - <string name="audio">Áudio</string> - <string name="cancel_recording">Cancelar Gravação</string> - <string name="cancel">Cancelar</string> - <string name="chapters">Capítulos</string> - <string name="choose_stream">Escolher %1$s</string> - <string name="clear_image_cache">Eliminar cache de imagens</string> - <string name="collection">Coleção</string> - <string name="collections">Coleções</string> - <string name="confirm">Confirmar</string> - <string name="decimal_seconds">%.2f segundos</string> - <string name="default_track">Padrão</string> - <string name="delete">Eliminar</string> - <string name="directed_by">Realizado por %1$s</string> - <string name="director">Realizador</string> - <string name="disabled">Desativado</string> - <string name="downloading">A transferir…</string> - <string name="enabled">Activado</string> - <string name="episodes">Episódios</string> - <string name="external_track">Externo</string> - <string name="favorites">Favoritos</string> - <string name="file_size">Tamanho</string> - <string name="forced_track">Forçado</string> - <string name="go_to">Ir para</string> - <string name="active_recordings">Gravações Ativas</string> - <string name="birthplace">Local de Nascimento</string> - <string name="bitrate">Bitrate</string> - <string name="born">Nascido</string> - <string name="cancel_series_recording">Cancelar Gravação da Série</string> - <string name="compose_error_message_title">Ocorreu um erro! Pressiona o botão para enviar o registo para o teu servidor.</string> - <string name="continue_watching">Continuar a ver</string> - <string name="died">Morreu</string> - <string name="discovered_servers">Servidores Detectados</string> - <string name="download_and_update"><![CDATA[Transferir e Atualizar]]></string> - <string name="enter_server_url">Introduza o IP ou URL do Servidor</string> - <string name="error_loading_collection">Erro ao carregar a colecção %1$s</string> - <string name="genres">Géneros</string> - <string name="go_to_series">Ir para a Série</string> - <string name="hide_controller_timeout">Esconder controlos de reprodução</string> - <string name="hide_debug_info">Esconder informação de depuração</string> - <string name="hide">Esconder</string> - <string name="home">Início</string> - <string name="immediate">Imediato</string> - <string name="intro">Introdução</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Biblioteca</string> - <string name="license_info">Informação da licença</string> - <string name="live_tv">TV em Directo</string> - <string name="loading">A carregar…</string> - <string name="mark_entire_series_as_played">Marcar toda a série como reproduzida?</string> - <string name="mark_entire_series_as_unplayed">Marcar toda a série como não reproduzida?</string> - <string name="mark_unwatched">Marcar como não visto</string> - <string name="mark_watched">Marcar como visto</string> - <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_title">Filmes</string> - <string name="name">Nome</string> - <string name="next_up">A Seguir</string> - <string name="no_data">Sem dados</string> - <string name="no_results">Sem resultados</string> - <string name="no_scheduled_recordings">Sem gravações programadas</string> - <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_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> - <string name="playback_debug_info">Mostrar depuração da reprodução</string> - <string name="playback_speed">Velocidade da Reprodução</string> - <string name="playback">Reprodução</string> - <string name="playlist">Lista de Reprodução</string> - <string name="playlists">Listas de Reprodução</string> - <string name="preview">Pré-visualização</string> - <string name="community_rating">Avaliação da Comunidade</string> - <string name="critic_rating">Avaliação da Crítica</string> - <string name="profile_specific_settings">Definições de Utilizador</string> - <string name="queue">Fila</string> - <string name="recap">Recapitulação</string> - <string name="recently_added_in">Adicionado recentemente em %1$s</string> - <string name="recently_added">Adicionado recentemente</string> - <string name="recently_recorded">Gravado Recentemente</string> - <string name="recently_released">Lançado Recentemente</string> - <string name="recommended">Recomendado</string> - <string name="record_program">Gravar Programa</string> - <string name="record_series">Gravar Série</string> - <string name="remove_favorite">Desmarcar como favorito</string> - <string name="restart">Reiniciar</string> - <string name="resume">Resumir</string> - <string name="save">Guardar</string> - <string name="search_and_download"><![CDATA[Pesquisar e Transferir]]></string> - <string name="search">Pesquisar</string> - <string name="searching">A pesquisar…</string> - <string name="select_server">Selecionar Servidor</string> - <string name="select_user">Selecionar Utilizador</string> - <string name="show_debug_info">Mostrar informação de Depuração</string> - <string name="show_next_up_when">Mostrar \'A Seguir\'</string> - <string name="show">Mostrar</string> - <string name="shuffle">Aleatório</string> - <string name="skip">Saltar</string> - <string name="sort_by_date_added">Data Adicionado</string> - <string name="sort_by_date_episode_added">Data Episódio Adicionado</string> - <string name="sort_by_date_played">Data Reproduzido</string> - <string name="sort_by_date_released">Data de Lançamento</string> - <string name="sort_by_name">Nome</string> - <string name="sort_by_random">Aleatória</string> - <string name="studios">Estúdios</string> - <string name="submit">Submeter</string> - <string name="subtitle_download_too_long">A transferência está a demorar muito, pode ser necessário reiniciar a reprodução</string> - <string name="subtitle">Legenda</string> - <string name="subtitles">Legendas</string> - <string name="suggestions">Sugestões</string> - <string name="switch_servers">Mudar Servidores</string> - <string name="switch_user">Mudar</string> - <string name="top_unwatched">Mais Votados Não Assistidos</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">Programação DVR</string> - <string name="tv_guide">Guia</string> - <string name="tv_season">Temporada</string> - <string name="tv_seasons">Temporadas</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> - <string name="version">Versão</string> - <string name="video_scale">Escala do Vídeo</string> - <string name="video">Vídeo</string> - <string name="videos">Vídeos</string> - <string name="watch_live">Ver em directo</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> - <string name="create_playlist">Criar nova lista de reprodução</string> - <string name="add_to_playlist">Adicionar a lista de reprodução</string> - <string name="one_click_pause">Pausar com um clique</string> - <string name="one_click_pause_summary_on">Pressione o centro do D-Pad para pausar/reproduzir</string> - <string name="italic_font">Itálico</string> - <string name="font">Fonte</string> - <string name="background">Fundo</string> - <string name="success">Êxito</string> - <string name="official_rating">Classificação Parental</string> - <string name="runtime_sort">Duração</string> - <string name="extras_title">Extras</string> - <plurals name="other_extras"> - <item quantity="one">Outro</item> - <item quantity="many">Outros</item> - <item quantity="other">Outros</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Nos Bastidores</item> - <item quantity="many">Nos Bastidores</item> - <item quantity="other">Nos Bastidores</item> - </plurals> - <plurals name="theme_songs"> - <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">Genérico</item> - <item quantity="many">Genéricos</item> - <item quantity="other">Genéricos</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Clipe</item> - <item quantity="many">Clipes</item> - <item quantity="other">Clipes</item> - </plurals> - <plurals name="deleted_scenes"> - <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">Entrevista</item> - <item quantity="many">Entrevistas</item> - <item quantity="other">Entrevistas</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Cena</item> - <item quantity="many">Cenas</item> - <item quantity="other">Cenas</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Amostra</item> - <item quantity="many">Amostras</item> - <item quantity="other">Amostras</item> - </plurals> - <plurals name="featurettes"> - <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">Curta</item> - <item quantity="many">Curtas</item> - <item quantity="other">Curtas</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s transferido</item> - <item quantity="many">%s transferidos</item> - <item quantity="other">%s transferidos</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d hora</item> - <item quantity="many">%d horas</item> - <item quantity="other">%d horas</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d item</item> - <item quantity="many">%d itens</item> - <item quantity="other">%d itens</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d segundo</item> - <item quantity="many">%d segundos</item> - <item quantity="other">%d segundos</item> - </plurals> - <string name="commercial">Publicidade</string> - <string name="path">Localização</string> - <string name="ac3_supported">Dispositivo suporta AC3/Dolby Digital</string> - <string name="advanced_settings">Definições Avançadas</string> - <string name="advanced_ui">Interface Avançado</string> - <string name="app_theme">Tema da Aplicação</string> - <string name="auto_check_for_updates">Verificar automaticamente se há atualizações</string> - <string name="auto_play_next_delay">Atraso antes de reproduzir \'A Seguir\'</string> - <string name="auto_play_next">Reproduzir automaticamente o próximo</string> - <string name="check_for_updates">Verificar se há atualizações</string> - <string name="combine_continue_next_summary">Aplica-se apenas a Séries de TV</string> - <string name="combine_continue_next"><![CDATA[Combinar Continuar a Ver e A Seguir]]></string> - <string name="direct_play_ass">Utilizar libass para legendas ASS</string> - <string name="direct_play_pgs">Reprodução direta de legendas PGS</string> - <string name="downmix_stereo">Conversão sempre em estéreo</string> - <string name="ffmpeg_extension_pref">Usar o módulo decodificador FFmpeg</string> - <string name="global_content_scale">Escala de conteúdo predefinida</string> - <string name="install_update">Instalar atualização</string> - <string name="installed_version">Versão instalada</string> - <string name="max_homepage_items">Máximo de itens por fila no inicio</string> - <string name="nav_drawer_pins_summary">Escolha os itens padrão a serem exibidos, os demais serão ocultados</string> - <string name="nav_drawer_pins">Personalizar itens da barra de navegação</string> - <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 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> - <string name="seek_bar_steps">Passos da barra de pesquisa</string> - <string name="send_app_logs_summary">Útil para depuração</string> - <string name="send_app_logs">Enviar registos da aplicação para o servidor atual</string> - <string name="send_crash_reports_summary">Tentará enviar para o último servidor conectado</string> - <string name="send_crash_reports">Enviar Relatórios de Falhas</string> - <string name="settings">Definições</string> - <string name="skip_back_on_resume_preference">Retroceder ao retomar a reprodução</string> - <string name="skip_back_preference">Retroceder</string> - <string name="skip_commercials_behavior">Comportamento de ignorar anúncios</string> - <string name="skip_forward_preference">Avançar</string> - <string name="skip_intro_behavior">Comportamento de saltar introdução</string> - <string name="skip_outro_behavior">Comportamento de saltar créditos finais</string> - <string name="skip_previews_behavior">Comportamento de saltar pré-visualizações</string> - <string name="skip_recap_behavior">Comportamento de saltar recapitulação</string> - <string name="update_available">Atualização disponível</string> - <string name="update_url_summary">URL usado para verificar atualizações da aplicação</string> - <string name="update_url">URL de atualizações</string> - <string name="font_size">Tamanho da fonte</string> - <string name="font_color">Cor da fonte</string> - <string name="font_opacity">Opacidade da fonte</string> - <string name="edge_style">Estilo da borda</string> - <string name="edge_color">Cor da borda</string> - <string name="background_opacity">Opacidade do fundo</string> - <string name="background_style">Estilo do fundo</string> - <string name="subtitle_style">Estilo da legenda</string> - <string name="background_color">Cor do fundo</string> - <string name="reset">Repor</string> - <string name="bold_font">Fonte em negrito</string> - <string name="player_backend">Motor de reprodução</string> - <string name="mpv_hardware_decoding">MPV: Usar descodificação por hardware</string> - <string name="disable_if_crash">Desative se ocorrerem falhas</string> - <string name="mpv_options">Opções do MPV</string> - <string name="exoplayer_options">Opções do ExoPlayer</string> - <string name="skip_segment_unknown">Saltar Segmento</string> - <string name="skip_segment_commercial">Saltar Publicidade</string> - <string name="skip_segment_preview">Saltar pré-visualização</string> - <string name="skip_segment_recap">Saltar Recapitulação</string> - <string name="skip_segment_outro">Saltar Créditos Finais</string> - <string name="skip_segment_intro">Saltar Introdução</string> - <string name="played">Reproduzido</string> - <string name="filter">Filtrar</string> - <string name="year">Ano</string> - <string name="decade">Década</string> - <string name="remove">Remover</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> - <string name="mpv_conf">Editar mpv.conf</string> - <string name="discard_change">Anular alterações?</string> - <string name="subtitle_margin">Margem</string> - <string name="verbose_logging">Registro detalhado</string> - <string name="enter_pin">Inserir PIN</string> - <string name="sign_in_auto">Entrar automaticamente</string> - <string name="use_server_credentials">Iniciar sessão através do servidor</string> - <string name="press_enter_to_confirm">Pressione centro para confirmar</string> - <string name="require_pin_code">Exigir PIN para o perfil</string> - <string name="confirm_pin">Confirmar PIN</string> - <string name="incorrect">Incorrecto</string> - <string name="pin_too_short">O PIN deve ter 4 dígitos ou mais</string> - <string name="will_remove_pin">Irá remover o PIN</string> - <string name="image_cache_size">Tamanho da cache de imagens no disco (MB)</string> - <string name="view_options">Ver opções</string> - <string name="columns">Colunas</string> - <string name="spacing">Espaçamento</string> - <string name="aspect_ratio">Proporção da imagem</string> - <string name="show_details">Mostrar detalhes</string> - <string name="image_type">Tipo de imagem</string> - <string name="cast_and_crew"><![CDATA[Elenco e Equipa Técnica]]></string> - <string name="guest_stars">Estrelas convidadas</string> - <string name="edge_size">Dimensão da borda</string> - <string name="refresh_rate_switching">Mudança da taxa de atualização</string> - <string name="automatic">Automático</string> - <string name="username_or_password">Usar utilizador/palavra-passe</string> - <string name="login">Entrar</string> - <string name="show_titles">Mostrar títulos</string> - <string name="media_information">Informação da Multimédia</string> - <string name="general">Geral</string> - <string name="title">Título</string> - <string name="codec">Codec</string> - <string name="profile">Perfil</string> - <string name="level">Nível</string> - <string name="resolution">Resolução</string> - <string name="anamorphic">Anamórfico</string> - <string name="interlaced">Intercalado</string> - <string name="framerate">Taxa de fotogramas</string> - <string name="bit_depth">Profundidade de bit</string> - <string name="video_range">Gama de video</string> - <string name="video_range_type">Tipo de gama de vídeo</string> - <string name="color_space">Espaço de cor</string> - <string name="color_transfer">Transferência de cor</string> - <string name="color_primaries">Cores primárias</string> - <string name="pixel_format">Formato dos pixeis</string> - <string name="ref_frames">Quadros de referência</string> - <string name="nal">NAL</string> - <string name="language">Idioma</string> - <string name="layout">Disposição</string> - <string name="channels">Canais</string> - <string name="sample_rate">Taxa de amostragem</string> - <string name="avc">AVC</string> - <string name="yes">Sim</string> - <string name="no">Não</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="container">Formato</string> - <string name="live_tv_repeat">Repetir</string> - <string name="favorite_channels_at_beginning">Mostrar primeiro os canais favoritos</string> - <string name="sort_channels_recently_watched">Ordenar canais por visualizações recentes</string> - <string name="color_code_programs">Codificação de cores para os programas</string> - <string name="subtitle_delay">Atraso da legenda</string> - <string name="enter_server_address">Insira o endereço do servidor</string> - <string name="no_servers_found">Nenhum servidor encontrado</string> - <string name="backdrop_display">Estilo de fundo</string> - <string name="ends_at">Termina às %1$s</string> - <string name="resolution_switching">Mudança de resolução</string> - <string name="local">Local</string> - <string name="play_trailer">Reproduzir trailer</string> - <string name="no_trailers">Sem trailers</string> - <string name="clear_track_choices">Apagar seleções de faixas</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="only_forced_subtitles">Apenas Legendas Forçadas</string> - <string name="force_dovi_profile_7">Reprodução direta Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">Ignora as verificações de compatibilidade do dispositivo</string> - <string name="discover">Descobrir</string> - <string name="request">Pedir</string> - <string name="pending">Pendente</string> - <string name="seerr_integration">Integração com Seerr</string> - <string name="remove_seerr_server">Remover Servidor Seerr</string> - <string name="seerr_server_added">Servidor Seerr adicionado</string> - <string name="url">URL</string> - <string name="trending">Popular</string> - <string name="upcoming_movies">Filmes em Breve</string> - <string name="upcoming_tv">Séries em Breve</string> - <string name="request_4k">Pedir em 4K</string> - <string name="password">Palavra-passe</string> - <string name="username">Utilizador</string> - <string name="voice_search">Pesquisa por voz</string> - <string name="voice_starting">A Iniciar</string> - <string name="voice_search_prompt">Fale para pesquisar</string> - <string name="processing">A Processar</string> - <string name="press_back_to_cancel">Pressione recuar para cancelar</string> - <string name="voice_error_audio">Erro na gravação de áudio</string> - <string name="voice_error_client">Erro no reconhecimento de voz</string> - <string name="voice_error_permissions">É necessária permissão para o microfone</string> - <string name="voice_error_network">Erro de rede</string> - <string name="voice_error_network_timeout">Tempo limite da rede</string> - <string name="voice_error_no_match">Nenhuma fala reconhecida</string> - <string name="voice_error_busy">Reconhecimento de voz ocupado</string> - <string name="voice_error_server">Erro do servidor</string> - <string name="voice_error_speech_timeout">Nenhuma fala detetada</string> - <string name="voice_error_unknown">Erro desconhecido</string> - <string name="voice_error_start_failed">Falha ao iniciar o reconhecimento de voz</string> - <string name="voice_error_timeout">Tempo limite do reconhecimento de voz expirado</string> - <string name="retry">Tentar novamente</string> - <string name="software_decoding_av1">Decodificação de AV1 por software</string> - <string name="upgrade_mpv_toast">O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições.</string> - <plurals name="days"> - <item quantity="one">%s Dia</item> - <item quantity="many">%s Dias</item> - <item quantity="other">%s Dias</item> - </plurals> - <string name="hdr_subtitle_style">Estilo de legenda HDR</string> - <string name="image_subtitle_opacity">Transparência da legenda da imagem</string> - <string name="brightness">Brilho</string> - <string name="contrast">Contraste</string> - <string name="saturation">Saturação</string> - <string name="hue">Matiz</string> - <string name="red">Vermelho</string> - <string name="green">Verde</string> - <string name="blue">Azul</string> - <string name="blur">Desfoque</string> - <string name="save_for_album">Guardar para o álbum</string> - <string name="play_slideshow">Reproduzir slides</string> - <string name="stop_slideshow">Parar slides</string> - <string name="slideshow_at_beginning">No início</string> - <string name="no_more_images">Não há mais fotos</string> - <string name="rotate_left">Rodar para a esquerda</string> - <string name="rotate_right">Rodar para a direita</string> - <string name="zoom_in">Ampliar</string> - <string name="zoom_out">Diminuir</string> - <string name="slideshow_duration">Duração dos slides</string> - <string name="play_videos_during_slideshow">Reproduzir vídeos durante a apresentação de slides</string> - <string name="send_media_info_log_to_server">Enviar registo de informações de mídia para o servidor</string> - <string name="no_limit">Sem limite</string> - <string name="max_days_next_up">Dias máximos em \'A Seguir\'</string> - <string name="quick_connect">Ligação Rápida</string> - <string name="quick_connect_summary">Autorize outro dispositivo a iniciar sessão na sua conta</string> - <string name="quick_connect_code">Insira o código de Ligação Rápida</string> - <string name="quick_connect_code_error">Código deve ter 6 dígitos</string> - <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> - <string name="add_row">Adicionar linha</string> - <string name="genres_in">Géneros em %1$s</string> - <string name="recently_released_in">Lançado recentemente em %1$s</string> - <string name="height">Altura</string> - <string name="apply_all_rows">Aplicar a todas as linhas</string> - <string name="customize_home">Personalizar página inicial</string> - <string name="home_rows">Linhas da página inicial</string> - <string name="load_from_server">Carregar do perfil de utilizador do servidor</string> - <string name="save_to_server">Guardar no perfil de utilizador do servidor</string> - <string name="load_from_web_client">Carregar a partir do cliente web</string> - <string name="use_series">Usar imagem da série</string> - <string name="add_row_for">Adicionar linha para %1$s</string> - <string name="overwrite_server_settings">Substituir as definições no servidor?</string> - <string name="overwrite_local_settings">Substituir as definições locais?</string> - <string name="for_episodes">Por episódios</string> - <string name="suggestions_for">Sugestões para %1$s</string> - <string name="increase_all_cards_size">Aumentar o tamanho de todos os cartões</string> - <string name="use_thumb_images">Usar imagens de prévia</string> - <string name="settings_saved">Configurações salvas</string> - <string name="display_presets">Mostrar pré definições</string> - <string name="display_presets_description">Predefinições embarcadas para definir todas as linhas rapidamente</string> - <string name="customize_home_summary">Escolha as linhas e imagens na página inicial</string> - <string name="decrease_all_cards_size">Diminuir o tamanho de todos os cartões</string> - <string name="favorite_items_title">Favoritos %s</string> - <plurals name="minutes"> - <item quantity="one">%s minuto</item> - <item quantity="many">%s minutos</item> - <item quantity="other">%s minutos</item> - </plurals> - <string name="start_after">Começar depois</string> - <string name="animate">Animado</string> - <string name="max_age_rating">Idade máxima</string> - <string name="no_max">Sem máximo</string> - <string name="for_all_ages">Para todas as idades</string> - <string name="up_to_age">Até a idade %s</string> - <string name="screensaver">Proteção de ecrã</string> - <string name="screensaver_settings">Definições de protecção de ecrã</string> - <string name="start_screensaver">Iniciar protecção de ecrã</string> - <string name="duration">Duração</string> - <string name="photos">Fotos</string> - <string name="include_types">Incluir tipos</string> - <string name="include_types_summary">Que tipos de itens devem mostrar imagens</string> - <string name="content_scale_fit">Ajustar</string> - <string name="content_scale_crop">Recortar</string> - <string name="content_scale_fill">Preencher</string> - <string name="content_scale_fill_width">Preencher largura</string> - <string name="content_scale_fill_height">Preencher altura</string> - <string name="display_preset_default">Padrão do Wolfskin</string> - <string name="display_preset_compact">Wolfskin Compacto</string> - <string name="display_preset_series_thumb">Imagens em miniatura da série</string> - <string name="display_preset_episode_thumbnails">Imagens em miniatura do episódio</string> - <string name="volume_lowest">Mínimo</string> - <string name="volume_low">Baixo</string> - <string name="volume_medium">Médio</string> - <string name="volume_high">Alto</string> - <string name="volume_full">Máximo</string> - <string name="purple">Roxo</string> - <string name="orange">Laranja</string> - <string name="bold_blue">Azul intenso</string> - <string name="black">Preto</string> - <string name="skip_ignore">Ignorar</string> - <string name="skip_automatically">Saltar automaticamente</string> - <string name="skip_ask">Pedir para saltar</string> - <string name="ffmpeg_fallback">Usa FFmpeg apenas se não houver um descodificador integrado</string> - <string name="ffmpeg_prefer">Prefere o FFmpeg aos descodificadores integrados</string> - <string name="ffmpeg_never">Nunca usar os descodificadores FFmpeg</string> - <string name="next_up_playback_end">No final da reprodução</string> - <string name="next_up_outro">Durante os créditos finais</string> - <string name="white">Branco</string> - <string name="light_gray">Cinzento claro</string> - <string name="dark_gray">Cinzento escuro</string> - <string name="yellow">Amarelo</string> - <string name="cyan">Ciano</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contorno</string> - <string name="subtitle_edge_shadow">Sombra</string> - <string name="background_style_wrap">Envolver</string> - <string name="background_style_boxed">Com caixa</string> - <string name="prefer_mpv">Preferir MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</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">Quadrado (1:1)</string> - <string name="image_type_primary">Primária</string> - <string name="image_type_thumb">Miniatura</string> - <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> - <string name="backdrop_style_image">Apenas imagem</string> - <string name="enter_url_api_key"><![CDATA[Inserir URL e Chave API]]></string> - <string name="api_key">Chave API</string> - <string name="seerr_login">Iniciar sessão no servidor Seerr</string> - <string name="seerr_jellyfin_user">Utilizador Jellyfin</string> - <string name="seerr_local_user">Utilizador local</string> - <string name="search_and_download_subtitles"><![CDATA[Procurar e transferir legendas]]></string> - <string name="no_subtitles_found">Não foram encontradas legendas remotas</string> - <string name="series_continueing">Em curso</string> - <string name="in_app_screensaver">Usar protetor de ecrã da aplicação</string> - <string name="actor">Ator</string> - <string name="composer">Compositor</string> - <string name="writer">Argumentista</string> - <string name="guest_star">Convidado especial</string> - <string name="producer">Produtor</string> - <string name="conductor">Director musicar</string> - <string name="lyricist">Letrista</string> - <string name="arranger">Arranjador</string> - <string name="engineer">Engenheiro de som</string> - <string name="mixer">Técnico de mixagem</string> - <string name="creator">Criador</string> - <string name="artist">Artista</string> - <string name="delete_item">Tem a certeza de que deseja eliminar este elemento?</string> - <string name="show_media_management">Mostrar opções de gestão de multimédia</string> - <string name="search_for">Pesquisar %s</string> - <string name="select_all">Selecionar tudo</string> - <string name="albums">Álbuns</string> - <string name="artists">Artistas</string> - <string name="songs">Músicas</string> - <string name="go_to_artist">Ir para o artista</string> - <string name="now_playing">A reproduzir</string> - <string name="instant_mix">Mistura instantânea</string> - <string name="go_to_album">Ir para o álbum</string> - <string name="add_to_queue">Adicionar à fila</string> - <string name="album_artist">Artista do álbum</string> - <string name="album">Álbum</string> - <string name="popular_songs">Mais ouvidas</string> - <string name="play_next">Reproduzir a seguir</string> - <string name="music_videos">Videoclips</string> - <string name="lyrics">Letras</string> - <string name="hide_lyrics">Esconder letras</string> - <string name="show_lyrics">Mostrar letras</string> - <string name="song_has_lyrics">Música contêm letras</string> - <string name="remove_from_queue">Remover da fila</string> - <string name="show_album_cover">Mostrar capa do álbum</string> - <string name="show_visualizer">Mostrar visualizador</string> - <string name="show_backdrop">Mostrar imagem de fundo</string> - <string name="play_as_type">Reproduzir como?</string> - <string name="visualizer_rationale">Para visualizar o áudio que está a ser reproduzido, é necessária autorização para gravar áudio.</string> - <string name="continue_string">Continuar</string> - <string name="separate_types">Separar por tipos</string> - <string name="prefer_logos">Prefiro mostrar logótipos nos títulos</string> - <string name="updated_toast">Wolfskin foi atualizado para %s</string> - <string name="remove_continue_watching">Remover de «Continuar a ver»</string> - <string name="remove_next_up">Remover série do «Continuar a ver»</string> - <string name="view_removed_next_up">Ver séries removidas do «Continuar a ver»</string> - <string name="view_more">Ver mais</string> - <string name="discover_tv">Descobrir Séries</string> - <string name="discover_movies">Descobrir Filmes</string> - <string name="studios_in">Estúdios em %1$s</string> - <string name="next_up_never">Nunca</string> - <string name="external_player">Reprodutor Externo</string> - <string name="last_played">Última reprodução</string> - <string name="system_default">Padrão do sistema</string> - <string name="ass_subtitle_mode_libass">Reprodução directa com libass</string> - <string name="ass_subtitle_mode_exoplayer">Reprodução directa com ExoPlayer integrado</string> - <string name="ass_subtitle_mode_transcode">Incorporar/transcodificar no servidor</string> - <string name="ass_subtitle_playback">Reprodução de legendas SSA/ASS</string> - <string name="cinema_mode">Modo Cinema</string> - <string name="show_more">Mostrar %1$s mais</string> - <string name="skip_behavior">Comportamento de saltar</string> - <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="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> - <string name="remove_from_playlist">Remover da lista de reprodução</string> - <string name="login_credentials_expired">As credenciais expiraram. Inicie sessão novamente</string> - <string name="login_not_authorized">Não tem autorização para aceder ao servidor neste momento</string> - <string name="top_rated">Mais votados</string> - <string name="hdr_formats">Formato HDR</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="force_enable">Ativação forçada</string> - <string name="force_disable">Desativação forçada</string> - <string name="prefer_dovi_over_hdr10_plus">Preferir Dolby Vision sobre HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+</string> - <string name="convert_dovi_profile_7">Converte Dolby Vision Profile 7 para 8.1</string> - <string name="convert_dovi_profile_7_summary">Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7</string> - <string name="server_version_not_supported_future">A versão do servidor deixará de ser suportada em breve</string> - <string name="server_version_required">Versão do servidor necessária: %1$s</string> - <string name="max_resolution">Resolução máxima antes da transcodificação</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Episódio</item> - <item quantity="many">%1$d Episódios</item> - <item quantity="other">%1$d Episódios</item> - </plurals> - <string name="chosen_by">Escolhido por %1$s</string> - <string name="user">Utilizador</string> - <string name="series">Séries</string> - <string name="load_from_server_plugin">Carregar as predefinições do servidor</string> - <string name="save_to_local">Guardar apenas neste dispositivo</string> - <string name="home_settings_source_unset">Padrão</string> - <string name="home_settings_source_local">Específico do dispositivo</string> - <string name="home_settings_source_server_profile">Perfil de utilizador do servidor</string> - <string name="home_settings_source_plugin">Padrão do servidor</string> - <string name="home_settings_source_web">Utilizador do cliente web</string> - <string name="managed_by_server">Gerido pelo servidor</string> - <string name="error">Erro</string> </resources> diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml deleted file mode 100644 index d6a2d917..00000000 --- a/app/src/main/res/values-ro/strings.xml +++ /dev/null @@ -1,392 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Despre</string> - <string name="add_favorite">Favorit</string> - <string name="add_server">Adaugă server</string> - <string name="add_user">Adaugă utilizator</string> - <string name="audio">Audio</string> - <string name="birthplace">Locul nașterii</string> - <string name="born">Născut</string> - <string name="cancel_recording">Anulează înregistrarea</string> - <string name="cancel">Anulează</string> - <string name="chapters">Capitole</string> - <string name="choose_stream">Alege %1$s</string> - <string name="collection">Colecție</string> - <string name="collections">Colecții</string> - <string name="downloading">Se descarcă…</string> - <string name="directed_by">Regizat de %1$s</string> - <string name="clear_image_cache">Golește memoria cache a imaginilor</string> - <string name="confirm">Confirmă</string> - <string name="director">Regizor</string> - <string name="favorites">Favorite</string> - <string name="ends_at">Se încheie la %1$s</string> - <string name="enter_server_url">Introdu adresa IP sau URL-ul</string> - <string name="enter_server_address">Introdu adresa serverului</string> - <string name="episodes">Episoade</string> - <string name="error_loading_collection">Eroare la încărcarea colecției %1$s</string> - <string name="external_track">Extern</string> - <string name="file_size">Dimensiune</string> - <string name="enabled">Activat</string> - <string name="delete">Șterge</string> - <string name="continue_watching">Continuă vizionarea</string> - <string name="decimal_seconds">%.2f secunde</string> - <string name="disabled">Dezactivat</string> - <string name="genres">Genuri</string> - <string name="intro">Introducere</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="name">Nume</string> - <string name="next_up">În continuare</string> - <string name="no_results">Nu există rezultate</string> - <string name="no_servers_found">Nu s-au găsit servere</string> - <string name="no_scheduled_recordings">Nu sunt înregistrări programate</string> - <string name="no_update_available">Nu este disponibilă nicio actualizare</string> - <plurals name="movies"> - <item quantity="one">Filme</item> - <item quantity="other">Filme</item> - </plurals> - <string name="library">Librărie</string> - <string name="go_to">Mergi la</string> - <string name="go_to_series">Mergi la seria</string> - <string name="loading">Se încarcă…</string> - <string name="mark_entire_series_as_played">Marchez întreaga serie ca vizionată?</string> - <string name="mark_entire_series_as_unplayed">Marchez întreaga serie ca nevizionată?</string> - <string name="mark_unwatched">Marchează ca nevizionat</string> - <string name="mark_watched">Marchează ca vizionat</string> - <plurals name="people"> - <item quantity="one">Persoane</item> - <item quantity="other">Persoane</item> - </plurals> - <string name="play_count">Număr de redări</string> - <string name="more">Mai mult</string> - <string name="home">Acasă</string> - <string name="no_data">Nu există date</string> - <string name="outro">Încheiere</string> - <string name="path">Cale</string> - <string name="more_like_this">Mai multe articole similare</string> - <string name="hide">Ascunde</string> - <string name="language">Limbă</string> - <string name="resolution">Rezoluție</string> - <string name="title">Titlu</string> - <string name="columns">Coloane</string> - <string name="confirm_pin">Confirmă PIN</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="enter_pin">Introdu PIN</string> - <string name="sign_in_auto">Conectare automată</string> - <string name="pin_too_short">PIN-ul trebuie sa aibă 4 sau mai multe caractere</string> - <string name="discard_change">Anulezi modificările?</string> - <string name="year">An</string> - <string name="decade">Decadă</string> - <string name="filter">Filtru</string> - <string name="played">Redat</string> - <string name="mpv_options">Opțiuni MPV</string> - <string name="exoplayer_options">Opțiuni ExoPlayer</string> - <string name="mpv_conf">Modifică mpv.conf</string> - <string name="spacing">Spațiere</string> - <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">Distribuție și echipă</string> - <string name="guest_stars">Vedete invitate</string> - <string name="level">Nivel</string> - <string name="channels">Canale</string> - <string name="yes">Da</string> - <string name="no">Nu</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="show_titles">Arată titlurile</string> - <string name="live_tv_repeat">Repetă</string> - <string name="favorite_channels_at_beginning">Afișează mai întâi canalele favorite</string> - <string name="sort_channels_recently_watched">Sortează canalele după vizionarea recentă</string> - <string name="subtitle_delay">Decalare subitrare</string> - <string name="discover">Descoperă</string> - <string name="play_trailer">Redă trailer</string> - <string name="no_trailers">Fără trailere</string> - <string name="seerr_integration">Integrare Seerr</string> - <string name="remove_seerr_server">Elimină serverul Seerr</string> - <string name="seerr_server_added">Serverul Seer adăugat</string> - <string name="quick_connect">Conectare rapidă</string> - <string name="quick_connect_summary">Autorizează un alt dispozitiv să se conecteze la contul tău</string> - <string name="quick_connect_code">Introdu codul de conectare rapidă</string> - <string name="quick_connect_code_error">Codul trebuie să aibă 6 cifre</string> - <string name="quick_connect_success">Dispozitiv autorizat cu success</string> - <string name="password">Parolă</string> - <string name="username">Nume utilizator</string> - <string name="url">Adresă URL</string> - <string name="trending">În tendințe</string> - <string name="upcoming_movies">Filme viitoare</string> - <string name="upcoming_tv">Seriale viitoare</string> - <string name="request_4k">Solicită în format 4K</string> - <string name="brightness">Luminozitate</string> - <string name="contrast">Contrast</string> - <string name="saturation">Saturație</string> - <string name="hue">Nuanță</string> - <string name="red">Roșu</string> - <string name="green">Verde</string> - <string name="blue">Albastru</string> - <string name="blur">Estompare</string> - <string name="save_for_album">Salvează pentru album</string> - <string name="albums">Albume</string> - <string name="artists">Artiști</string> - <string name="songs">Cântece</string> - <string name="go_to_artist">Mergi la artist</string> - <string name="now_playing">Se redă acum</string> - <string name="instant_mix">Mix instant</string> - <string name="go_to_album">Mergi la album</string> - <string name="add_to_queue">Adaugă în coadă</string> - <string name="add_row">Adaugă rând</string> - <string name="genres_in">Genuri în %1$s</string> - <string name="recently_released_in">Lansate recent în %1$s</string> - <string name="height">Înălțime</string> - <string name="apply_all_rows">Aplică la toate rândurile</string> - <string name="customize_home">Personalizează pagina de pornire</string> - <string name="for_episodes">Pentru episoade</string> - <string name="suggestions_for">Sugestii pentru %1$s</string> - <string name="increase_all_cards_size">Mărește dimensiunea tuturor cardurilor</string> - <string name="decrease_all_cards_size">Micșorează dimensiunea tuturor cardurilor</string> - <string name="settings_saved">Setări salvate</string> - <string name="duration">Durată</string> - <string name="photos">Fotografii</string> - <string name="volume_lowest">Cel mai scăzut</string> - <string name="volume_low">Scăzut</string> - <string name="volume_medium">Mediu</string> - <string name="volume_high">Ridicat</string> - <string name="volume_full">Maxim</string> - <string name="purple">Violet</string> - <string name="orange">Portocaliu</string> - <string name="white">Alb</string> - <string name="light_gray">Gri deschis</string> - <string name="dark_gray">Gri închis</string> - <string name="yellow">Galben</string> - <string name="cyan">Cian</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Contur</string> - <string name="subtitle_edge_shadow">Umbră</string> - <string name="prefer_mpv">Preferă MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Folosește ExoPlayer pentru redare HDR</string> - <string name="aspect_ratios_square">Pătrat (1:1)</string> - <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">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> - <string name="default_track">Implicit</string> - <string name="died">Decedat</string> - <string name="active_recordings">Înregistrări active</string> - <string name="critic_rating">Evaluarea criticilor</string> - <string name="hide_controller_timeout">Ascunde comenzile de redare</string> - <string name="live_tv">TV în direct</string> - <string name="play">Redă</string> - <string name="recap">Recapitulare</string> - <string name="recently_added">Adăugat recent</string> - <string name="recently_recorded">Înregistrat recent</string> - <string name="recently_released">Lansat recent</string> - <string name="recommended">Recomandat</string> - <string name="playlists">Liste de redare</string> - <string name="preview">Previzualizare</string> - <string name="profile_specific_settings">Setări profil utilizator</string> - <string name="queue">Coadă</string> - <string name="recently_added_in">Adăugat recent în %1$s</string> - <string name="search">Caută</string> - <string name="searching">Se caută…</string> - <string name="voice_search">Căutare vocală</string> - <string name="processing">Se procesează</string> - <string name="select_server">Alege server</string> - <string name="select_user">Alege utilizator</string> - <string name="skip">Sări peste</string> - <string name="retry">Reîncearcă</string> - <string name="show">Arată</string> - <string name="studios">Studiouri</string> - <string name="submit">Trimite</string> - <string name="subtitle_download_too_long">Descărcarea durează mult; s-ar putea să fie necesar să repornești redarea</string> - <string name="subtitle">Subtitrare</string> - <string name="subtitles">Subtitrări</string> - <string name="suggestions">Sugestii</string> - <string name="switch_servers">Schimbă serverele</string> - <string name="switch_user">Schimbă</string> - <string name="tv_season">Sezon</string> - <string name="tv_seasons">Sezoane</string> - <plurals name="tv_shows"> - <item quantity="one">Seriale TV</item> - <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> - <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="other">Interviuri</item> - </plurals> - <string name="background">Fundal</string> - <plurals name="downloads"> - <item quantity="one">%s descărcare</item> - <item quantity="few">%s descărcări</item> - <item quantity="other">%s descărcări</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d oră</item> - <item quantity="few">%d ore</item> - <item quantity="other">%d ore</item> - </plurals> - <plurals name="days"> - <item quantity="one">%s zi</item> - <item quantity="few">%s zile</item> - <item quantity="other">%s zile</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d articol</item> - <item quantity="few">%d articole</item> - <item quantity="other">%d articole</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d secundă</item> - <item quantity="few">%d secunde</item> - <item quantity="other">%d secunde</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">%s minut</item> - <item quantity="few">%s minute</item> - <item quantity="other">%s minute</item> - </plurals> - <string name="check_for_updates">Verifică dacă sunt actualizări</string> - <string name="install_update">Instalează actualizarea</string> - <string name="installed_version">Versiune instalată</string> - <string name="settings">Setări</string> - <string name="font_size">Dimensiune font</string> - <string name="font_color">Culoare font</string> - <string name="font_opacity">Opacitate font</string> - <string name="edge_style">Stil margine</string> - <string name="background_color">Culoare fundal</string> - <string name="reset">Resetare</string> - <string name="update_available">Actualizare disponibilă</string> - <string name="update_url_summary">URL folosit pentru a căuta actualizări</string> - <string name="update_url">URL actualizare</string> - <string name="send_crash_reports">Trimite rapoarte de eroare</string> - <string name="subtitle_style">Stil subtitrare</string> - <string name="background_style">Stil fundal</string> - <string name="skip_segment_intro">Sări peste introducere</string> - <string name="skip_segment_commercial">Sări peste reclame</string> - <string name="skip_segment_preview">Sări peste previzualizare</string> - <string name="skip_segment_recap">Sări peste recapitulare</string> - <string name="incorrect">Incorect</string> - <string name="username_or_password">Folosește utilizator/parolă</string> - <string name="request">Solicitare</string> - <string name="pending">În așteptare</string> - <string name="upgrade_mpv_toast">MPV este acum playerul implicit, cu excepția conținutului HDR.\nPoți modifica aceasta din setări.</string> - <string name="hdr_subtitle_style">Stilul subtitrărilor HDR</string> - <string name="rotate_right">Rotește la dreapta</string> - <string name="for_all_ages">Pentru toate vârstele</string> - <string name="up_to_age">Până la vârsta de %s</string> - <string name="screensaver">Economizor de ecran</string> - <string name="screensaver_settings">Setări economizor de ecran</string> - <string name="skip_automatically">Sări automat</string> - <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">Caută & descarcă subtitrări</string> - <string name="composer">Compozitor</string> - <string name="writer">Scriitor</string> - <string name="lyrics">Versuri</string> - <string name="hide_lyrics">Ascunde versurile</string> - <string name="show_lyrics">Afișează versurile</string> - <string name="song_has_lyrics">Cântecul are versuri</string> - <string name="select_all">Selectează tot</string> - <string name="play_as_type">Redă ca?</string> - <string name="cancel_series_recording">Anulează înregistrarea seriei</string> - <string name="license_info">Informații privind licența</string> - <string name="forced_track">Forțat</string> - <string name="hide_debug_info">Ascunde informațiile de depanare</string> - <string name="immediate">Imediat</string> - <string name="none">Niciuna</string> - <string name="play_from_here">Redă de aici</string> - <string name="playback_speed">Viteza de redare</string> - <string name="playback">Redare</string> - <string name="playlist">Listă de redare</string> - <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">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> - <string name="restart">Repornire</string> - <string name="voice_error_network">Eroare rețea</string> - <string name="shuffle">Amestecă</string> - <string name="top_unwatched">Cele mai bine cotate, nevizionate</string> - <plurals name="trailers"> - <item quantity="one">Trailere</item> - <item quantity="other">Trailere</item> - </plurals> - <string name="play_with_transcoding">Redă cu transcodare</string> - <string name="media_information">Informații media</string> - <string name="create_playlist">Creează o listă noua de redare</string> - <string name="add_to_playlist">Adaugă în lista de redare</string> - <plurals name="other_extras"> - <item quantity="one">Altele</item> - <item quantity="other">Altele</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">În spatele scenelor</item> - <item quantity="other">În spatele scenelor</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Melodii tematice</item> - <item quantity="other">Melodii tematice</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Scene șterse</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_title">Extra</string> - <plurals name="clips"> - <item quantity="one">Clipuri</item> - <item quantity="other">Clipuri</item> - </plurals> - <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> - <string name="advanced_settings">Setări avansate</string> - <string name="advanced_ui">Interfață avansată</string> - <string name="auto_play_next">Redă automat următorul</string> - <string name="combine_continue_next_summary">Se aplică numai serialelor TV</string> - <string name="nav_drawer_switch_on_focus_summary_off">Faceți clic pentru a schimba paginile</string> - <string name="skip_segment_unknown">Sări peste segment</string> - <string name="skip_segment_outro">Sări peste final</string> - <string name="use_server_credentials">Autentificare prin server</string> - <string name="press_enter_to_confirm">Apăsați în centru pentru a confirma</string> - <string name="require_pin_code">Solicită codul PIN pentru profil</string> - <string name="view_options">Opțiuni de vizualizare</string> - <string name="login">Autentificare</string> - <string name="layout">Aspect</string> - <string name="rotate_left">Rotește la stânga</string> - <string name="overwrite_server_settings">Suprascrie setările pe server?</string> - <string name="overwrite_local_settings">Suprascrie setările locale?</string> - <string name="start_after">Începe după</string> - <string name="include_types_summary">Pentru ce tipuri de articole să se afișeze imagini</string> - <string name="black">Negru</string> - <string name="skip_ignore">Ignoră</string> - <string name="next_up_playback_end">La sfârșitul redării</string> - <string name="next_up_outro">În timpul genericului de final/outro</string> - <string name="bold_blue">Albastru aldin</string> - <string name="bold_font">Font aldin</string> - <string name="player_backend">Motorul de redare</string> - <string name="mpv_hardware_decoding">MPV: Utilizează decodarea hardware</string> - <string name="disable_if_crash">Dezactivează această opțiune dacă aplicația se blochează</string> - <string name="background_opacity">Opacitatea fundalului</string> - <string name="profile">Profil</string> -</resources> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml deleted file mode 100644 index ca59ccb6..00000000 --- a/app/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,805 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">О приложении</string> - <string name="add_favorite">В избранное</string> - <string name="add_server">Добавить сервер</string> - <string name="add_user">Добавить пользователя</string> - <string name="audio">Аудио</string> - <string name="birthplace">Место рождения</string> - <string name="bitrate">Битрейт</string> - <string name="born">Дата рождения</string> - <string name="cancel_recording">Отменить запись</string> - <string name="cancel">Отмена</string> - <string name="chapters">Главы</string> - <string name="choose_stream">Выбор: %1$s</string> - <string name="clear_image_cache">Очистить кэш изображений</string> - <string name="collection">Коллекция</string> - <string name="collections">Коллекции</string> - <string name="community_rating">Оценка сообщества</string> - <string name="compose_error_message_title">Произошла ошибка! Нажмите кнопку, чтобы отправить журналы на ваш сервер.</string> - <string name="confirm">Подтвердить</string> - <string name="continue_watching">Продолжить просмотр</string> - <string name="critic_rating">Оценка критиков</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="downloading">Скачивание…</string> - <string name="enter_server_url">Введите IP или URL сервера</string> - <string name="episodes">Эпизоды</string> - <string name="error_loading_collection">Ошибка при загрузке коллекции %1$s</string> - <string name="favorites">Избранное</string> - <string name="ac3_supported">Устройство поддерживает AC3/Dolby Digital</string> - <string name="advanced_settings">Расширенные настройки</string> - <string name="app_theme">Тема оформления</string> - <string name="auto_check_for_updates">Автоматически проверять наличие обновлений</string> - <string name="check_for_updates">Проверить наличие обновлений</string> - <string name="downmix_stereo">Всегда сводить звук в стерео</string> - <string name="ffmpeg_extension_pref">Использовать декодер FFmpeg</string> - <string name="file_size">Размер</string> - <string name="genres">Жанры</string> - <string name="hide">Скрыть</string> - <string name="intro">Вступление</string> - <string name="library">Библиотека</string> - <string name="license_info">Информация о лицензии</string> - <string name="live_tv">Прямой эфир</string> - <string name="loading">Загрузка…</string> - <string name="mark_unwatched">Отметить как непросмотренное</string> - <string name="mark_watched">Отметить как просмотренное</string> - <string name="max_bitrate">Максимальный битрейт</string> - <string name="more_like_this">Похожее</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_title">Люди</string> - <string name="playback_debug_info">Показать отладочную информацию о воспроизведении</string> - <string name="playback_speed">Скорость воспроизведения</string> - <string name="playback">Воспроизведение</string> - <string name="playlist">Плейлист</string> - <string name="playlists">Плейлисты</string> - <string name="profile_specific_settings">Настройки профиля</string> - <string name="queue">Очередь</string> - <string name="recently_added_in">Недавно добавленное в разделе «%1$s»</string> - <string name="recently_added">Недавно добавленное</string> - <string name="recommended">Рекомендации</string> - <string name="remove_favorite">Убрать из избранного</string> - <string name="resume">Продолжить</string> - <string name="save">Сохранить</string> - <string name="search_and_download">Найти и скачать</string> - <string name="search">Поиск</string> - <string name="searching">Поиск…</string> - <string name="select_server">Выберите сервер</string> - <string name="select_user">Выберите пользователя</string> - <string name="show_debug_info">Показать отладочную информацию</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="sort_by_date_played">Дата воспроизведения</string> - <string name="sort_by_date_released">Дата выхода</string> - <string name="sort_by_name">Название</string> - <string name="sort_by_random">Случайно</string> - <string name="studios">Студии</string> - <string name="subtitle">Субтитры</string> - <string name="subtitles">Субтитры</string> - <string name="suggestions">Предложения</string> - <string name="switch_servers">Сменить сервер</string> - <string name="switch_user">Сменить</string> - <string name="top_unwatched">Лучшие из непросмотренных</string> - <plurals name="trailers"> - <item quantity="one">Трейлер</item> - <item quantity="few">Трейлера</item> - <item quantity="many">Трейлеров</item> - <item quantity="other">Трейлеров</item> - </plurals> - <string name="tv_guide">Телегид</string> - <string name="tv_season">Сезон</string> - <string name="tv_seasons">Сезоны</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="play_with_transcoding">Воспроизвести с перекодированием</string> - <string name="show_clock">Показывать часы</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="font">Шрифт</string> - <string name="background">Фон</string> - <string name="success">Успех</string> - <string name="official_rating">Возрастной рейтинг</string> - <string name="runtime_sort">Продолжительность</string> - <string name="extras_title">Дополнительные материалы</string> - <plurals name="other_extras"> - <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> - <item quantity="many">За кадром</item> - <item quantity="other">За кадром</item> - </plurals> - <plurals name="clips"> - <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> - <item quantity="many">Удалённых сцен</item> - <item quantity="other">Удалённых сцен</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Интервью</item> - <item quantity="few">Интервью</item> - <item quantity="many">Интервью</item> - <item quantity="other">Интервью</item> - </plurals> - <plurals name="scenes"> - <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> - <item quantity="few">%s загрузки</item> - <item quantity="many">%s загрузок</item> - <item quantity="other">%s загрузок</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d час</item> - <item quantity="few">%d часа</item> - <item quantity="many">%d часов</item> - <item quantity="other">%d часов</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d секунда</item> - <item quantity="few">%d секунды</item> - <item quantity="many">%d секунд</item> - <item quantity="other">%d секунд</item> - </plurals> - <string name="active_recordings">Активные записи</string> - <string name="external_track">Внешний</string> - <string name="go_to">Перейти</string> - <string name="skip_segment_commercial">Пропустить рекламу</string> - <string name="skip_segment_intro">Пропустить вступление</string> - <string name="recently_recorded">Недавно записанное</string> - <string name="recently_released">Недавно вышедшее</string> - <string name="commercial">Реклама</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ</string> - <string name="mark_entire_series_as_played">Отметить весь сериал как просмотренный?</string> - <string name="mark_entire_series_as_unplayed">Отметить весь сериал как непросмотренный?</string> - <string name="hide_debug_info">Скрыть отладочную информацию</string> - <string name="home">Главная</string> - <string name="disabled">Отключено</string> - <string name="enabled">Включено</string> - <string name="hide_controller_timeout">Задержка скрытия элементов управления</string> - <string name="more">Ещё</string> - <string name="no_scheduled_recordings">Нет запланированных записей</string> - <string name="play">Воспроизвести</string> - <string name="restart">С начала</string> - <string name="play_from_here">Воспроизвести отсюда</string> - <string name="watch_live">Смотреть прямой эфир</string> - <string name="aired_episode_order">Порядок выхода эпизодов</string> - <string name="reset">Сброс</string> - <string name="cast_and_crew"><![CDATA[Актёры и съёмочная группа]]></string> - <string name="guest_stars">Приглашённые звёзды</string> - <string name="aspect_ratio">Соотношение сторон</string> - <string name="show_details">Показать подробности</string> - <string name="spacing">Интервал</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_conf">Редактировать mpv.conf</string> - <string name="discard_change">Отменить изменения?</string> - <string name="enter_pin">Введите ПИН-код</string> - <string name="sign_in_auto">Автоматический вход</string> - <string name="pin_too_short">ПИН-код должен содержать не менее 4 знаков</string> - <string name="mpv_options">Настройки MPV</string> - <string name="exoplayer_options">Настройки ExoPlayer</string> - <string name="year">Год</string> - <string name="decade">Десятилетие</string> - <string name="remove">Убрать</string> - <string name="mpv_hardware_decoding">MPV: использовать аппаратное декодирование</string> - <string name="filter">Фильтр</string> - <string name="outro">Заключительные титры</string> - <string name="preview">Превью</string> - <string name="submit">Отправить</string> - <string name="subtitle_download_too_long">Загрузка занимает много времени, возможно, потребуется перезапустить воспроизведение</string> - <string name="cancel_series_recording">Отменить запись сериала</string> - <string name="none">Нет</string> - <string name="ui_interface">Интерфейс</string> - <string name="record_program">Записать программу</string> - <string name="record_series">Записать сериал</string> - <string name="play_count">Количество воспроизведений</string> - <string name="recap">Краткое содержание</string> - <string name="use_server_credentials">Вход через сервер</string> - <string name="press_enter_to_confirm">Нажмите центральную кнопку для подтверждения</string> - <string name="require_pin_code">Требовать ПИН-код профиля</string> - <string name="confirm_pin">Подтвердите ПИН-код</string> - <string name="incorrect">Неверно</string> - <string name="view_options">Параметры отображения</string> - <string name="columns">Колонки</string> - <string name="subtitle_style">Стиль субтитров</string> - <string name="background_color">Цвет фона</string> - <string name="bold_font">Жирный шрифт</string> - <string name="background_style">Стиль фона</string> - <string name="image_type">Тип изображения</string> - <string name="will_remove_pin">ПИН-код будет удалён</string> - <string name="image_cache_size">Размер кэша изображений на диске (Мбайт)</string> - <string name="skip_segment_recap">Пропустить краткое содержание</string> - <string name="skip_segment_outro">Пропустить заключительные титры</string> - <string name="verbose_logging">Подробный журнал</string> - <string name="disable_if_crash">Отключите при нестабильной работе</string> - <string name="mpv_use_gpu_next">MPV: использовать gpu-next</string> - <string name="immediate">Сразу</string> - <string name="subtitle_margin">Отступ</string> - <string name="skip_segment_unknown">Пропустить сегмент</string> - <string name="skip_segment_preview">Пропустить превью</string> - <string name="settings">Настройки</string> - <string name="skip_back_on_resume_preference">Перемотка назад при возобновлении воспроизведения</string> - <string name="skip_back_preference">Перемотка назад</string> - <string name="skip_commercials_behavior">Поведение при пропуске рекламы</string> - <string name="skip_forward_preference">Перемотка вперёд</string> - <string name="skip_intro_behavior">Поведение при пропуске вступления</string> - <string name="skip_outro_behavior">Поведение при пропуске заключительных титров</string> - <string name="skip_previews_behavior">Поведение при пропуске превью</string> - <string name="skip_recap_behavior">Поведение при пропуске краткого содержания</string> - <string name="update_available">Доступно обновление</string> - <string name="update_url_summary">URL для проверки обновлений приложения</string> - <string name="played">Воспроизведено</string> - <string name="send_crash_reports">Отправлять отчёты об ошибках</string> - <string name="show_next_up_when">Когда показывать «Далее»</string> - <string name="italic_font">Курсив</string> - <string name="advanced_ui">Расширенный интерфейс</string> - <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">Использовать libass для ASS-субтитров</string> - <string name="direct_play_pgs">Прямое воспроизведение PGS-субтитров</string> - <string name="font_size">Размер шрифта</string> - <string name="font_color">Цвет шрифта</string> - <string name="font_opacity">Непрозрачность шрифта</string> - <string name="edge_style">Стиль обводки</string> - <string name="edge_color">Цвет обводки</string> - <string name="background_opacity">Непрозрачность фона</string> - <string name="install_update">Установить обновление</string> - <string name="installed_version">Установленная версия</string> - <string name="send_app_logs">Отправить журнал на текущий сервер</string> - <string name="send_crash_reports_summary">Приложение попытается отправить отчёты на последний подключённый сервер</string> - <string name="go_to_series">Перейти к сериалу</string> - <string name="tv_dvr_schedule">Расписание записей</string> - <string name="update_url">URL для обновлений</string> - <plurals name="items"> - <item quantity="one">%d объект</item> - <item quantity="few">%d объекта</item> - <item quantity="many">%d объектов</item> - <item quantity="other">%d объектов</item> - </plurals> - <plurals name="samples"> - <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> - <item quantity="many">Дополнительных материалов</item> - <item quantity="other">Дополнительных материалов</item> - </plurals> - <string name="player_backend">Выбор плеера</string> - <string name="edge_size">Толщина обводки</string> - <string name="global_content_scale">Масштаб по умолчанию</string> - <string name="max_homepage_items">Максимум элементов в ряду на главной</string> - <string name="forced_track">Форсированные</string> - <plurals name="theme_songs"> - <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> - <item quantity="many">Тематических видео</item> - <item quantity="other">Тематических видео</item> - </plurals> - <string name="nav_drawer_pins">Настроить элементы боковой панели</string> - <string name="nav_drawer_pins_summary">Выберите элементы для показа на панели, остальные элементы будут скрыты</string> - <string name="play_theme_music">Воспроизведение фоновой музыки</string> - <string name="playback_overrides">Переопределения воспроизведения</string> - <string name="remember_selected_tab">Запоминать выбранные вкладки</string> - <string name="rewatch_next_up">Разрешить повторный просмотр в «Далее»</string> - <string name="seek_bar_steps">Интервал перемотки</string> - <string name="send_app_logs_summary">Используется при отладке</string> - <string name="nav_drawer_switch_on_focus_summary_off">Смена страниц по нажатию</string> - <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> - <item quantity="many">Короткометражек</item> - <item quantity="other">Короткометражек</item> - </plurals> - <string name="ends_at">Конец в %1$s</string> - <string name="enter_server_address">Введите адрес сервера</string> - <string name="no_servers_found">Серверы не найдены</string> - <string name="only_forced_subtitles">Только форсированные субтитры</string> - <string name="voice_search">Голосовой поиск</string> - <string name="voice_starting">Запуск</string> - <string name="voice_search_prompt">Произнесите поисковый запрос</string> - <string name="processing">Обработка</string> - <string name="press_back_to_cancel">Нажмите «Назад» для отмены</string> - <string name="voice_error_audio">Ошибка записи звука</string> - <string name="voice_error_client">Ошибка распознавания голоса</string> - <string name="voice_error_permissions">Требуется разрешение на использование микрофона</string> - <string name="voice_error_network">Сетевая ошибка</string> - <string name="voice_error_network_timeout">Время ожидания сети истекло</string> - <string name="voice_error_no_match">Речь не распознана</string> - <string name="voice_error_busy">Служба распознавания речи занята</string> - <string name="voice_error_server">Ошибка сервера</string> - <string name="voice_error_speech_timeout">Речь не обнаружена</string> - <string name="voice_error_unknown">Неизвестная ошибка</string> - <string name="voice_error_start_failed">Не удалось запустить распознавание голоса</string> - <string name="voice_error_timeout">Время ожидания распознавания голоса истекло</string> - <string name="retry">Повторить</string> - <string name="media_information">Информация о медиафайле</string> - <string name="refresh_rate_switching">Переключение частоты обновления</string> - <string name="automatic">Автоматически</string> - <string name="username_or_password">Войти с именем пользователя и паролем</string> - <string name="login">Войти</string> - <string name="password">Пароль</string> - <string name="username">Имя пользователя</string> - <string name="language">Язык</string> - <string name="general">Основное</string> - <string name="container">Контейнер</string> - <string name="title">Название</string> - <string name="codec">Кодек</string> - <string name="profile">Профиль</string> - <string name="level">Уровень</string> - <string name="resolution">Разрешение</string> - <string name="anamorphic">Анаморфность</string> - <string name="interlaced">Чересстрочность</string> - <string name="framerate">Частота кадров</string> - <string name="bit_depth">Разрядность</string> - <string name="video_range">Диапазон видео</string> - <string name="video_range_type">Тип диапазона видео</string> - <string name="color_space">Цветовое пространство</string> - <string name="color_transfer">Передаточная характеристика</string> - <string name="color_primaries">Основные цвета</string> - <string name="pixel_format">Формат пикселей</string> - <string name="ref_frames">Опорные кадры</string> - <string name="yes">Да</string> - <string name="no">Нет</string> - <plurals name="days"> - <item quantity="one">%s день</item> - <item quantity="few">%s дня</item> - <item quantity="many">%s дней</item> - <item quantity="other">%s дней</item> - </plurals> - <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="channels">Каналы</string> - <string name="sample_rate">Частота дискретизации</string> - <string name="contrast">Контрастность</string> - <string name="saturation">Насыщенность</string> - <string name="hue">Оттенок</string> - <string name="red">Красный</string> - <string name="green">Зелёный</string> - <string name="blue">Синий</string> - <string name="brightness">Яркость</string> - <string name="seerr_integration">Интеграция с Seerr</string> - <string name="search_for">Найти %s</string> - <string name="select_all">Выбрать всё</string> - <string name="subtitle_delay">Задержка субтитров</string> - <string name="play_trailer">Воспроизвести трейлер</string> - <string name="no_trailers">Трейлеров нет</string> - <string name="duration">Длительность</string> - <string name="photos">Фотографии</string> - <string name="purple">Фиолетовый</string> - <string name="orange">Оранжевый</string> - <string name="black">Чёрный</string> - <string name="skip_automatically">Пропускать автоматически</string> - <string name="white">Белый</string> - <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">Гц</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> - <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_hours_seconds">%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="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</string> - <string name="other_extras_title">Другое</string> - <string name="samples_title">Образцы</string> - <string name="featurettes_title">Дополнительные материалы</string> - <string name="shorts_title">Короткометражки</string> - <string name="disable_audio_offload">Отключить аудиоофлоад</string> - <string name="bit_unit">бит</string> - <string name="color_code_programs">Выделять программы цветом</string> - <string name="hdr_subtitle_style">Стиль субтитров для HDR</string> - <string name="image_subtitle_opacity">Непрозрачность графических субтитров</string> - <string name="save_for_album">Сохранить настройки для альбома</string> - <string name="slideshow_at_beginning">В начале</string> - <string name="max_days_next_up">Макс. дней в разделе «Далее»</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="add_row">Добавить ряд</string> - <string name="genres_in">Жанры раздела «%1$s»</string> - <string name="recently_released_in">Недавно вышедшее в разделе «%1$s»</string> - <string name="height">Высота</string> - <string name="apply_all_rows">Применить ко всем рядам</string> - <string name="customize_home">Настроить главный экран</string> - <string name="home_rows">Ряды на главном экране</string> - <string name="load_from_server">Загрузить из профиля пользователя на сервере</string> - <string name="save_to_server">Сохранить в профиль пользователя на сервере</string> - <string name="load_from_web_client">Загрузить из веб-клиента</string> - <string name="use_series">Использовать изображение сериала</string> - <string name="add_row_for">Добавить ряд: %1$s</string> - <string name="overwrite_server_settings">Перезаписать настройки на сервере?</string> - <string name="overwrite_local_settings">Перезаписать локальные настройки?</string> - <string name="for_episodes">Для эпизодов</string> - <string name="suggestions_for">Рекомендации для раздела «%1$s»</string> - <string name="increase_all_cards_size">Увеличить размер всех карточек</string> - <string name="decrease_all_cards_size">Уменьшить размер всех карточек</string> - <string name="use_thumb_images">Использовать миниатюры</string> - <string name="settings_saved">Настройки сохранены</string> - <string name="display_presets">Готовые варианты оформления</string> - <string name="display_presets_description">Готовые варианты для быстрой настройки оформления всех рядов</string> - <string name="customize_home_summary">Выбор рядов и изображений на главном экране</string> - <string name="prefer_ac3_for_surround_summary"><![CDATA[Перекодировать нестереофонический звук в AC3/Dolby Digital]]></string> - <string name="prefer_ac3_for_surround">Использовать AC3 для объёмного звука</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="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">Wolfskin по умолчанию</string> - <string name="display_preset_compact">Wolfskin компактный</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="bold_blue">Насыщенный синий</string> - <string name="brown">Коричневый</string> - <string name="skip_ignore">Игнорировать</string> - <string name="skip_ask">Спрашивать о пропуске</string> - <string name="ffmpeg_prefer">Предпочитать FFmpeg встроенным декодерам</string> - <string name="next_up_playback_end">В конце воспроизведения</string> - <string name="next_up_outro">Во время титров/концовки</string> - <string name="next_up_never">Никогда</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="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="separate_types">Разделять по типам</string> - <string name="prefer_logos">Показывать логотипы вместо текстовых названий</string> - <string name="updated_toast">Wolfskin обновлён до версии %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="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="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">Полоса перемотки с миниатюрами кадров (Trickplay)</string> - <string name="dpad_seek_mode_none_summary">Без полосы перемотки</string> - <string name="blocklisted">В чёрном списке</string> - <string name="unavailable">Недоступно</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="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="appears_on">Альбомы с участием исполнителя</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="remove_from_playlist">Убрать из плейлиста</string> - <string name="login_credentials_expired">Срок действия учётных данных истёк. Пожалуйста, войдите снова</string> - <string name="login_not_authorized">У вас нет доступа к серверу в данный момент</string> - <string name="top_rated">С высоким рейтингом</string> - <string name="hdr_formats">Форматы HDR</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision, профиль 5</string> - <string name="dovi_profile_7">Dolby Vision, профиль 7</string> - <string name="dovi_profile_8">Dolby Vision, профиль 8</string> - <string name="force_enable">Принудительно включить</string> - <string name="force_disable">Принудительно отключить</string> - <string name="prefer_dovi_over_hdr10_plus">Приоритет Dolby Vision над HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+</string> - <string name="server_version_not_supported_future">Поддержка этой версии сервера скоро прекратится</string> - <string name="server_version_required">Требуемая версия сервера: %1$s</string> - <string name="max_resolution">Максимальное разрешение без перекодирования</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <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="chosen_by">Источник выбора: %1$s</string> - <string name="user">Пользователь</string> - <string name="series">Сериал</string> - <string name="load_from_server_plugin">Загрузить настройки по умолчанию с сервера</string> - <string name="save_to_local">Сохранить только на этом устройстве</string> - <string name="home_settings_source_unset">По умолчанию</string> - <string name="home_settings_source_local">Настройки этого устройства</string> - <string name="home_settings_source_server_profile">Профиль пользователя на сервере</string> - <string name="home_settings_source_plugin">Настройки по умолчанию на сервере</string> - <string name="home_settings_source_web">Веб-клиент пользователя</string> - <string name="managed_by_server">Управляется сервером</string> - <string name="error">Ошибка</string> -</resources> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 50d2edd7..45c38853 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">O aplikácii</string> + <string name="about">O Wholphine</string> <string name="active_recordings">Aktívne nahrávky</string> <string name="add_favorite">Obľúbené</string> <string name="add_server">Pridať server</string> @@ -22,7 +22,7 @@ <string name="confirm">Potvrdiť</string> <string name="continue_watching">Pokračovať v pozeraní</string> <string name="critic_rating">Hodnotenie kritikov</string> - <string name="decimal_seconds">%.2f sekúnd</string> + <string name="decimal_seconds">%.1f sekúnd</string> <string name="default_track">Predvolené</string> <string name="delete">Vymazať</string> <string name="died">Zomrel</string> @@ -33,7 +33,7 @@ <string name="download_and_update"><![CDATA[Stiahnuť a aktualizovať]]></string> <string name="downloading">Sťahovanie…</string> <string name="enabled">Povolené</string> - <string name="enter_server_url">Zadajte IP adresu alebo URL servera</string> + <string name="enter_server_url">Zadajte IP adresu alebo URL servera vrátane portu</string> <string name="episodes">Epizódy</string> <string name="error_loading_collection">Chyba pri načítaní kolekcie %1$s</string> <string name="external_track">Externé</string> @@ -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_title">Filmy</string> + <string name="movies">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_title">Ľudia</string> + <string name="people">Ľ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> @@ -101,682 +101,4 @@ <string name="select_server">Vybrať server</string> <string name="select_user">Vybrať používateľa</string> <string name="show_debug_info">Zobraziť informácie o debugu</string> - <string name="compose_error_message_title">Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server.</string> - <string name="ends_at">Končí o %1$s</string> - <string name="enter_server_address">Zadajte adresu servera</string> - <string name="no_servers_found">Nenašli sa žiadne servery</string> - <string name="only_forced_subtitles">Iba vynútené titulky</string> - <string name="voice_search">Hlasové vyhľadávanie</string> - <string name="voice_starting">Spúšťanie</string> - <string name="voice_search_prompt">Hovorením vyhľadávajte</string> - <string name="processing">Spracovanie</string> - <string name="press_back_to_cancel">Stlačte späť pre zrušenie</string> - <string name="voice_error_audio">Chyba nahrávania zvuku</string> - <string name="voice_error_client">Chyba rozpoznávania hlasu</string> - <string name="voice_error_permissions">Vyžaduje sa povolenie na prístup k mikrofónu</string> - <string name="voice_error_network">Chyba siete</string> - <string name="voice_error_network_timeout">Časový limit siete</string> - <string name="voice_error_no_match">Nebol rozpoznaný žiadny hlas</string> - <string name="voice_error_busy">Rozpoznávanie hlasu je zaneprázdnené</string> - <string name="voice_error_server">Chyba servera</string> - <string name="voice_error_speech_timeout">Nebola zistená žiadna reč</string> - <string name="voice_error_unknown">Neznáma chyba</string> - <string name="voice_error_start_failed">Nepodarilo sa spustiť rozpoznávanie hlasu</string> - <string name="voice_error_timeout">Časový limit rozpoznávania hlasu vypršal</string> - <string name="retry">Skúsiť znova</string> - <string name="show_next_up_when">Zobraziť ďalšie</string> - <string name="show">Zobraziť</string> - <string name="shuffle">Náhodne</string> - <string name="skip">Preskočiť</string> - <string name="sort_by_date_added">Dátum pridania</string> - <string name="sort_by_date_episode_added">Dátum pridania epizódy</string> - <string name="sort_by_date_played">Dátum prehratia</string> - <string name="sort_by_date_released">Dátum vydania</string> - <string name="sort_by_name">Názov</string> - <string name="sort_by_random">Náhodne</string> - <string name="studios">Štúdiá</string> - <string name="submit">Odoslať</string> - <string name="subtitle_download_too_long">Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie</string> - <string name="subtitle">Podnázov</string> - <string name="subtitles">Titulky</string> - <string name="suggestions">Návrhy</string> - <string name="switch_servers">Zmeniť servery</string> - <string name="switch_user">Zmeniť</string> - <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> - <plurals name="trailers"> - <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á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> - <string name="updates">Aktualizácie</string> - <string name="version">Verzia</string> - <string name="video_scale">Mierka videa</string> - <string name="video">Video</string> - <string name="videos">Videá</string> - <string name="watch_live">Sledovať naživo</string> - <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> - <string name="aired_episode_order">Poradie odvysielaných epizód</string> - <string name="create_playlist">Vytvoriť nový playlist</string> - <string name="add_to_playlist">Pridať do playlistu</string> - <string name="one_click_pause">Pozastavenie jedným kliknutím</string> - <string name="one_click_pause_summary_on">Stlačte stred D-Padu pre pozastavenie/prehrávanie</string> - <string name="italic_font">Kurzívový font</string> - <string name="font">Font</string> - <string name="background">Pozadie</string> - <string name="success">Úspech</string> - <string name="official_rating">Rodičovské hodnotenie</string> - <string name="runtime_sort">Dĺžka</string> - <string name="extras_title">Bonusový materiál</string> - <plurals name="other_extras"> - <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">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á 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é 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">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é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">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é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">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áž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 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_title">Obľúbené %s</string> - <plurals name="downloads"> - <item quantity="one">%s stiahnutý</item> - <item quantity="few">%s stiahnuté</item> - <item quantity="many">%s stiahnutých</item> - <item quantity="other">%s stiahnutých</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d hodina</item> - <item quantity="few">%d hodiny</item> - <item quantity="many">%d hodín</item> - <item quantity="other">%d hodín</item> - </plurals> - <plurals name="days"> - <item quantity="one">%s deň</item> - <item quantity="few">%s dni</item> - <item quantity="many">%s dní</item> - <item quantity="other">%s dní</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d položka</item> - <item quantity="few">%d položky</item> - <item quantity="many">%d položiek</item> - <item quantity="other">%d položiek</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekunda</item> - <item quantity="few">%d sekundy</item> - <item quantity="many">%d sekúnd</item> - <item quantity="other">%d sekúnd</item> - </plurals> - <string name="ac3_supported">Zariadenie podporuje AC3/Dolby Digital</string> - <string name="advanced_settings">Pokročilé nastavenia</string> - <string name="advanced_ui">Pokročilé UI</string> - <string name="app_theme">Téma aplikácie</string> - <string name="auto_check_for_updates">Automaticky kontrolovať aktualizácie</string> - <string name="check_for_updates">Skontrolovať aktualizácie</string> - <string name="combine_continue_next_summary">Platí len pre seriály</string> - <string name="direct_play_ass">Použiť libass pre ASS titulky</string> - <string name="direct_play_pgs">Priame prehrávanie PGS titulkov</string> - <string name="downmix_stereo">Vždy downmixovať do stereo formátu</string> - <string name="ffmpeg_extension_pref">Použiť modul FFmpeg dekodéra</string> - <string name="global_content_scale">Predvolená mierka obsahu</string> - <string name="install_update">Nainštalovať aktualizáciu</string> - <string name="installed_version">Nainštalovaná verzia</string> - <string name="max_homepage_items">Maximálny počet položiek v riadkoch na domovskej obrazovke</string> - <string name="nav_drawer_pins_summary">Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté</string> - <string name="nav_drawer_pins">Prispôsobenie položiek navigačného panela</string> - <string name="nav_drawer_switch_on_focus_summary_off">Kliknutím prepnete stránky</string> - <string name="nav_drawer_switch_on_focus">Prepnúť stránky navigačného panelu pri zaostrení</string> - <string name="pass_out_protection">Ochrana proti výpadku</string> - <string name="play_theme_music">Prehrať tématickú hudbu</string> - <string name="remember_selected_tab">Zapamätať vybrané karty</string> - <string name="seek_bar_steps">Kroky seek baru</string> - <string name="send_app_logs_summary">Užitočné pre debugovanie</string> - <string name="send_app_logs">Odoslať logy aplikácie na aktuálny server</string> - <string name="send_crash_reports_summary">Pokus o odoslanie na posledný pripojený server</string> - <string name="send_crash_reports">Odoslať správy o zlyhaní</string> - <string name="settings">Nastavenia</string> - <plurals name="minutes"> - <item quantity="one">%s minúta</item> - <item quantity="few">%s minúty</item> - <item quantity="many">%s minút</item> - <item quantity="other">%s minút</item> - </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[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> - <string name="skip_back_preference">Preskočiť späť</string> - <string name="skip_commercials_behavior">Správanie preskočenia reklamy</string> - <string name="skip_forward_preference">Preskočiť dopredu</string> - <string name="skip_intro_behavior">Správanie preskočenia intra</string> - <string name="skip_outro_behavior">Správanie preskočenia outra</string> - <string name="skip_previews_behavior">Správanie preskočenia náhľadov</string> - <string name="skip_recap_behavior">Správanie preskočenia rekapitulácie</string> - <string name="update_available">Aktualizácia k dispozícii</string> - <string name="update_url_summary">URL používaná na kontrolu aktualizácií aplikácie</string> - <string name="update_url">Aktualizovať URL</string> - <string name="font_size">Veľkosť písma</string> - <string name="font_color">Farba písma</string> - <string name="font_opacity">Priehľadnosť písma</string> - <string name="edge_style">Štýl okraja</string> - <string name="edge_color">Farba okraja</string> - <string name="background_opacity">Prehľadnosť pozadia</string> - <string name="background_style">Štýl pozadia</string> - <string name="subtitle_style">Štýl titulkov</string> - <string name="background_color">Farba pozadia</string> - <string name="reset">Reset</string> - <string name="bold_font">Tučné písmo</string> - <string name="player_backend">Backend prehrávania</string> - <string name="mpv_hardware_decoding">MPV: Používa hardvérové dekódovanie</string> - <string name="disable_if_crash">Zakážte, ak dochádza k pádom</string> - <string name="mpv_options">Možnosti MPV</string> - <string name="exoplayer_options">Možnosti ExoPlayer</string> - <string name="skip_segment_unknown">Preskočiť segment</string> - <string name="skip_segment_commercial">Preskočiť reklamy</string> - <string name="skip_segment_preview">Preskočiť náhľad</string> - <string name="skip_segment_recap">Preskočiť rekapituláciu</string> - <string name="skip_segment_outro">Preskočiť outro</string> - <string name="skip_segment_intro">Preskočiť intro</string> - <string name="played">Prehrané</string> - <string name="filter">Filter</string> - <string name="year">Rok</string> - <string name="decade">Desaťročie</string> - <string name="remove">Odstrániť</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Použiť gpu-next</string> - <string name="mpv_conf">Upraviť mpv.conf</string> - <string name="discard_change">Zrušiť zmeny?</string> - <string name="subtitle_margin">Okraj</string> - <string name="verbose_logging">Podrobné logovanie</string> - <string name="enter_pin">Zadajte PIN</string> - <string name="sign_in_auto">Automatické prihlásenie</string> - <string name="use_server_credentials">Prihlásenie cez server</string> - <string name="press_enter_to_confirm">Stlačte stredové tlačidlo na potvrdenie</string> - <string name="require_pin_code">Vyžadovať PIN pre profil</string> - <string name="confirm_pin">Potvrdiť PIN</string> - <string name="incorrect">Nesprávny</string> - <string name="pin_too_short">PIN musí mať minimálne 4 znaky</string> - <string name="will_remove_pin">Odstráni PIN</string> - <string name="image_cache_size">Veľkosť pamäte obrázkov (MB)</string> - <string name="view_options">Možnosti zobrazenia</string> - <string name="columns">Stĺpce</string> - <string name="spacing">Rozostup</string> - <string name="aspect_ratio">Pomer strán</string> - <string name="show_details">Zobraziť podrobnosti</string> - <string name="image_type">Typ obrázku</string> - <string name="cast_and_crew"><![CDATA[Obsadenie a štáb]]></string> - <string name="guest_stars">Hosťujúce hviezdy</string> - <string name="edge_size">Veľkosť okraja</string> - <string name="refresh_rate_switching">Prepínanie obnovovacej frekvencie</string> - <string name="automatic">Automaticky</string> - <string name="username_or_password">Použite používateľské meno/heslo</string> - <string name="login">Prihlásenie</string> - <string name="general">Všeobecné</string> - <string name="container">Kontajner</string> - <string name="title">Titul</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Úroveň</string> - <string name="resolution">Rozlíšenie</string> - <string name="anamorphic">Anamorfný</string> - <string name="interlaced">Prekladané</string> - <string name="framerate">Snímková frekvencia</string> - <string name="bit_depth">Bitová hĺbka</string> - <string name="video_range">Rozsah videa</string> - <string name="video_range_type">Typ rozsahu videa</string> - <string name="color_space">Farebný priestor</string> - <string name="color_transfer">Prenos farieb</string> - <string name="color_primaries">Základné farby</string> - <string name="pixel_format">Formát pixelov</string> - <string name="ref_frames">Referenčné snímky</string> - <string name="nal">NAL</string> - <string name="language">Jazyk</string> - <string name="layout">Rozloženie</string> - <string name="channels">Kanály</string> - <string name="sample_rate">Vzorkovacia frekvencia</string> - <string name="avc">AVC</string> - <string name="yes">Áno</string> - <string name="no">Nie</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">Zobraziť názvy</string> - <string name="live_tv_repeat">Opakovať</string> - <string name="favorite_channels_at_beginning">Zobraziť obľúbené kanály ako prvé</string> - <string name="sort_channels_recently_watched">Zoradiť kanály podľa nedávno pozretých</string> - <string name="color_code_programs">Farebné rozlíšenie programov</string> - <string name="subtitle_delay">Oneskorenie titulkov</string> - <string name="backdrop_display">Štýl backdropu</string> - <string name="resolution_switching">Prepínanie rozlíšenia</string> - <string name="local">Lokálne</string> - <string name="play_trailer">Prehrať trailer</string> - <string name="no_trailers">Žiadne trailery</string> - <string name="clear_track_choices">Vyčistiť výber stôp</string> - <string name="dts_x">DTS:X</string> - <string name="dts_hd">DTS:HD</string> - <string name="truehd">TrueHD</string> - <string name="dolby_digital">DD</string> - <string name="dolby_digital_plus">DD+</string> - <string name="dts">DTS</string> - <string name="force_dovi_profile_7">Priame prehrávanie Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">Ignorovať kontrolu kompatibility zariadenia</string> - <string name="request">Žiadosť</string> - <string name="pending">Čaká</string> - <string name="seerr_integration">Integrácia Seerr</string> - <string name="remove_seerr_server">Odstrániť Seerr server</string> - <string name="seerr_server_added">Seerr server pridaný</string> - <string name="quick_connect">Rýchle pripojenie</string> - <string name="writer">Scenár</string> - <string name="guest_star">Hosťujúca hviezda</string> - <string name="producer">Producent</string> - <string name="conductor">Dirigent</string> - <string name="lyricist">Textár</string> - <string name="arranger">Aranžér</string> - <string name="engineer">Inžinier</string> - <string name="mixer">Zvukový mixér</string> - <string name="creator">Tvorca</string> - <string name="artist">Umelec</string> - <string name="search_for">Hľadať %s</string> - <string name="quick_connect_code">Zadajte kód Rýchleho pripojenia</string> - <string name="quick_connect_code_error">Kód musí mať 6 číslic</string> - <string name="quick_connect_success">Zariadenie bolo úspešne autorizované</string> - <string name="password">Heslo</string> - <string name="username">Používateľské meno</string> - <string name="url">URL</string> - <string name="trending">Trendujúce</string> - <string name="upcoming_movies">Nadchádzajúce filmy</string> - <string name="upcoming_tv">Nadchádzajúce seriály</string> - <string name="request_4k">Vyžiadať v 4K</string> - <string name="software_decoding_av1">Softvérové dekódovanie AV1</string> - <string name="upgrade_mpv_toast">MPV je teraz predvolený prehrávač okrem HDR.\nToto môžete zmeniť v nastaveniach.</string> - <string name="hdr_subtitle_style">Štýl HDR titulkov</string> - <string name="image_subtitle_opacity">Priehľadnosť obrázkových titulkov</string> - <string name="brightness">Jas</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Sýtosť</string> - <string name="hue">Odtieň</string> - <string name="red">Červená</string> - <string name="green">Zelená</string> - <string name="blue">Modrá</string> - <string name="blur">Rozmazanie</string> - <string name="save_for_album">Uložiť do albumu</string> - <string name="play_slideshow">Prehrať prezentáciu</string> - <string name="stop_slideshow">Zastaviť prezentáciu</string> - <string name="slideshow_at_beginning">Na začiatok</string> - <string name="no_more_images">Žiadne ďalšie fotky</string> - <string name="rotate_left">Otočiť doľava</string> - <string name="rotate_right">Otočiť doprava</string> - <string name="zoom_in">Zväčšiť</string> - <string name="zoom_out">Zmenšiť</string> - <string name="slideshow_duration">Trvanie prezentácie</string> - <string name="play_videos_during_slideshow">Prehrávať videá počas prezentácie</string> - <string name="no_limit">Bez obmedzenia</string> - <string name="dts_hd_ma">DTS:HD MA</string> - <string name="overwrite_server_settings">Prepísať nastavenia na serveri?</string> - <string name="overwrite_local_settings">Prepísať lokálne nastavenia?</string> - <string name="for_episodes">Pre epizódy</string> - <string name="suggestions_for">Návrhy pre %1$s</string> - <string name="increase_all_cards_size">Zväčšiť veľkosť všetkých kariet</string> - <string name="decrease_all_cards_size">Zmenšiť veľkosť všetkých kariet</string> - <string name="use_thumb_images">Použiť náhľadové obrázky</string> - <string name="settings_saved">Nastavenia uložené</string> - <string name="display_presets">Predvoľby displeja</string> - <string name="display_presets_description">Vstavané predvoľby na rýchlu úpravu všetkých riadkov</string> - <string name="customize_home_summary">Vyberte riadky a obrázky na domovskej stránke</string> - <string name="start_after">Začať po</string> - <string name="animate">Animovať</string> - <string name="max_age_rating">Maximálne vekové hodnotenie</string> - <string name="no_max">Bez vekového obmedzenia</string> - <string name="for_all_ages">Pre všetky vekové kategórie</string> - <string name="up_to_age">Do veku %s</string> - <string name="screensaver">Šetrič obrazovky</string> - <string name="screensaver_settings">Nastavenia šetriča obrazovky</string> - <string name="start_screensaver">Spustiť šetrič obrazovky</string> - <string name="duration">Trvanie</string> - <string name="photos">Fotografie</string> - <string name="include_types">Zahrnúť typy</string> - <string name="include_types_summary">Pre ktoré typy položiek zobrazovať obrázky</string> - <string name="content_scale_fit">Prispôsobiť</string> - <string name="content_scale_crop">Orezať</string> - <string name="content_scale_fill">Vyplniť</string> - <string name="content_scale_fill_width">Vyplniť na šírku</string> - <string name="content_scale_fill_height">Vyplniť na výšku</string> - <string name="display_preset_default">Wolfskin predvolené</string> - <string name="display_preset_compact">Wolfskin kompaktné</string> - <string name="display_preset_series_thumb">Náhľadové obrázky seriálov</string> - <string name="display_preset_episode_thumbnails">Náhľadové obrázky epizód</string> - <string name="image_type_thumb">Náhľad</string> - <string name="add_row">Pridať riadok</string> - <string name="genres_in">Žánre v %1$s</string> - <string name="recently_released_in">Nedávno vydané v %1$s</string> - <string name="height">Výška</string> - <string name="apply_all_rows">Použiť na všetky riadky</string> - <string name="customize_home">Prispôsobiť domovskú stránku</string> - <string name="home_rows">Riadky na domovskej stránke</string> - <string name="load_from_server">Načítať zo serverového profilu používateľa</string> - <string name="save_to_server">Uložiť do serverového profilu používateľa</string> - <string name="load_from_web_client">Načítať z webového klienta</string> - <string name="use_series">Použiť obrázok seriálu</string> - <string name="add_row_for">Pridať riadok pre %1$s</string> - <string name="volume_lowest">Najnižšia</string> - <string name="volume_low">Nízka</string> - <string name="volume_medium">Stredná</string> - <string name="volume_high">Vysoká</string> - <string name="volume_full">Plná</string> - <string name="purple">Fialová</string> - <string name="orange">Oranžová</string> - <string name="bold_blue">Výrazná modrá</string> - <string name="black">Čierna</string> - <string name="skip_ignore">Ignorovať</string> - <string name="skip_automatically">Preskočiť automaticky</string> - <string name="skip_ask">Opýtať sa na preskočenie</string> - <string name="ffmpeg_fallback">Použiť FFmpeg len v prípade, ak neexistuje žiadny vstavaný dekodér</string> - <string name="ffmpeg_prefer">Preferovať použitie FFmpeg pred vstavanými dekodérmi</string> - <string name="ffmpeg_never">Nikdy nepoužívať FFmpeg dekodéry</string> - <string name="next_up_playback_end">Na konci prehrávania</string> - <string name="next_up_outro">Počas záverečných titulkov/outro</string> - <string name="white">Biela</string> - <string name="light_gray">Svetlo šedá</string> - <string name="dark_gray">Tmavo šedá</string> - <string name="yellow">Žltá</string> - <string name="cyan">Azúrová</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Obrys</string> - <string name="subtitle_edge_shadow">Tieň</string> - <string name="background_style_wrap">Prispôsobiť pozadie textu</string> - <string name="background_style_boxed">Text v rámčeku</string> - <string name="prefer_mpv">Uprednostniť MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">Použiť ExoPlayer na prehrávanie HDR</string> - <string name="aspect_ratios_poster">Plagát (2:3)</string> - <string name="aspect_ratios_16_9">16:9</string> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Štvorec (1:1)</string> - <string name="image_type_primary">Primárny</string> - <string name="backdrop_style_dynamic">Obrázok s dynamickou farbou</string> - <string name="backdrop_style_image">Iba obrázok</string> - <string name="enter_url_api_key"><![CDATA[Zadajte URL a API kľúč]]></string> - <string name="api_key">API kľúč</string> - <string name="seerr_login">Prihlásenie na Seerr server</string> - <string name="seerr_jellyfin_user">Jellyfin používateľ</string> - <string name="seerr_local_user">Lokálny používateľ</string> - <string name="search_and_download_subtitles"><![CDATA[Vyhľadať a stiahnuť titulky]]></string> - <string name="no_subtitles_found">Nenašli sa žiadne vzdialené titulky</string> - <string name="series_continueing">Prebieha</string> - <string name="in_app_screensaver">Použiť šetrič obrazovky v aplikácii</string> - <string name="delete_item">Naozaj chcete odstrániť túto položku?</string> - <string name="show_media_management">Zobraziť možnosti správy médií</string> - <string name="actor">Herec</string> - <string name="composer">Skladateľ</string> - <string name="select_all">Vybrať všetko</string> - <string name="discover">Vyhľadať</string> - <string name="quick_connect_summary">Autorizujte iné zariadenie na prihlásenie do vášho účtu</string> - <string name="send_media_info_log_to_server">Odoslať log informácií o médiách na server</string> - <string name="max_days_next_up">Max. počet dní v sekcií Nasleduje</string> - <string name="albums">Albumy</string> - <string name="artists">Umelci</string> - <string name="songs">Skladby</string> - <string name="go_to_artist">Zobraziť umelca</string> - <string name="now_playing">Práve prehrávané</string> - <string name="instant_mix">Okamžitý mix</string> - <string name="go_to_album">Zobraziť album</string> - <string name="add_to_queue">Pridať do fronty</string> - <string name="next_up_never">Nikdy</string> - <string name="external_player">Externý prehrávač</string> - <string name="album_artist">Umelec albumu</string> - <string name="album">Album</string> - <string name="popular_songs">Populárne skladby</string> - <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">Wolfskin 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> - <string name="remove_from_playlist">Odstrániť z playlistu</string> - <string name="login_credentials_expired">Platnosť prihlasovacích údajov vypršala, prihláste sa znova</string> - <string name="login_not_authorized">Momentálne nemáte oprávnenie na prístup k serveru</string> - <string name="prefer_dovi_over_hdr10_plus">Preferovať Dolby Vision pred HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision</string> - <string name="max_resolution">Maximálne rozlíšenie pred prekódovaním</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d epizóda</item> - <item quantity="few">%1$d epizódy</item> - <item quantity="many">%1$d epizód</item> - <item quantity="other">%1$d epizód</item> - </plurals> </resources> diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml deleted file mode 100644 index 5711dac1..00000000 --- a/app/src/main/res/values-sl/strings.xml +++ /dev/null @@ -1,621 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">O aplikaciji</string> - <string name="active_recordings">Aktivni posnetki</string> - <string name="add_favorite">Dodaj med priljubljene</string> - <string name="add_server">Dodaj strežnik</string> - <string name="add_user">Dodaj uporabnika</string> - <string name="audio">Zvok</string> - <string name="birthplace">Rojstni kraj</string> - <string name="bitrate">Bitna hitrost</string> - <string name="born">Rojen</string> - <string name="cancel_recording">Prekliči Snemanje</string> - <string name="cancel_series_recording">Prekliči snemanje serije</string> - <string name="cancel">Prekliči</string> - <string name="chapters">Poglavja</string> - <string name="choose_stream">Izberi %1$s</string> - <string name="clear_image_cache">Počisti predpomnjene slike</string> - <string name="collection">Zbirka</string> - <string name="collections">Zbirke</string> - <string name="commercial">Komercialno</string> - <string name="community_rating">Ocena skupnosti</string> - <string name="compose_error_message_title">Prišlo je do napake! Pritisnite gumb, da pošljete napako na strežnik.</string> - <string name="confirm">Potrdi</string> - <string name="continue_watching">Nadaljujte z gledanjem</string> - <string name="critic_rating">Ocena kritikov</string> - <string name="decimal_seconds">%.2f sekund</string> - <string name="default_track">Privzeto</string> - <string name="delete">Izbriši</string> - <string name="died">Umrl</string> - <string name="directed_by">Režiral %1$s</string> - <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">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> - <string name="enter_server_url">Vnesite IP ali URL strežnika</string> - <string name="enter_server_address">Vnesite naslov strežnika</string> - <string name="episodes">Epizode</string> - <string name="error_loading_collection">Napaka nalaganja kolekcij %1$s</string> - <string name="external_track">Zunanji</string> - <string name="favorites">Priljubleni</string> - <string name="file_size">Velikost</string> - <string name="forced_track">Prisiljeno</string> - <string name="genres">Žanre</string> - <string name="go_to_series">Odpri serijo</string> - <string name="go_to">Pojdi na</string> - <string name="hide_controller_timeout">Skrij kontrole predvajalnika</string> - <string name="hide_debug_info">Skrij debug informacije</string> - <string name="hide">Skrij</string> - <string name="home">Domača stran</string> - <string name="immediate">Takoj</string> - <string name="intro">Uvod</string> - <string name="jump_letters">#ABCČDEFGHIJKLMNOPQRSŠTUVWXYZŽ</string> - <string name="library">Knjižnica</string> - <string name="license_info">Informacije o licenci</string> - <string name="live_tv">TV v živo</string> - <string name="loading">Nalaganje…</string> - <string name="mark_entire_series_as_played">Označi serijo kot predvajano?</string> - <string name="mark_entire_series_as_unplayed">Označi serijo kot nepredvajeno?</string> - <string name="mark_unwatched">Označi kot neogledano</string> - <string name="mark_watched">Označi kot ogledano</string> - <string name="max_bitrate">Največji bitrate</string> - <string name="more_like_this">Več takih</string> - <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> - <string name="no_data">Ni podatkov</string> - <string name="no_results">Ni rezultatov</string> - <string name="no_servers_found">Ni najdenih strežnikov</string> - <string name="no_scheduled_recordings">Ni načrtovanih posnetkov</string> - <string name="no_update_available">Ni novih posodobitev</string> - <string name="none">Nobeno</string> - <string name="only_forced_subtitles">Samo prisiljeni podanpisi</string> - <string name="outro">Outro</string> - <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> - <string name="play">Predvajaj</string> - <string name="playback_debug_info">Pokaži predvajalniške debug informacije</string> - <string name="playback_speed">Hitrost predvajanja</string> - <string name="playback">Predvajaj</string> - <string name="playlist">Seznam predvajanja</string> - <string name="playlists">Seznami predvajanja</string> - <string name="preview">Predogled</string> - <string name="profile_specific_settings">Nastavitve uporabniškega profila</string> - <string name="queue">Čakalna vrsta</string> - <string name="recap">Povzetek</string> - <string name="recently_added_in">Nedavno dodano v %1$s</string> - <string name="recently_added">Nedavno dodano</string> - <string name="recently_recorded">Nedavno posneto</string> - <string name="recently_released">Nedavno izdano</string> - <string name="recommended">Priporočeno</string> - <string name="record_program">Posnemi program</string> - <string name="record_series">Posnemi serijo</string> - <string name="remove_favorite">Odstrani iz priljubljenih</string> - <string name="restart">Zaženi znova</string> - <string name="resume">Nadaljuj</string> - <string name="save">Shrani</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> - <string name="voice_starting">Začetek</string> - <string name="voice_search_prompt">Izgovori za iskanje</string> - <string name="processing">Procesiranje</string> - <string name="press_back_to_cancel">Pritisnite nazaj, da prekličete</string> - <string name="voice_error_audio">Napaka pri snemanju zvoka</string> - <string name="voice_error_client">Napaka pri prepoznavanju glasu</string> - <string name="voice_error_permissions">Potrebno je dovoljenje do mikrofona</string> - <string name="voice_error_network">Napaka omrežja</string> - <string name="voice_error_no_match">Ni prepoznanega govora</string> - <string name="voice_error_busy">Prepoznavanje glasu zasedeno</string> - <string name="voice_error_server">Napaka strežnika</string> - <string name="voice_error_speech_timeout">Govor ni zaznan</string> - <string name="voice_error_unknown">Neznana napaka</string> - <string name="voice_error_start_failed">Prepoznavanje glasu ni uspelo zagnati</string> - <string name="voice_error_timeout">Časovna omejitev prepoznavanja glasu je potekla</string> - <string name="retry">Poskusi znova</string> - <string name="select_server">Izberite strežnik</string> - <string name="select_user">Izberite uporabnika</string> - <string name="show_debug_info">Prikaži informacije o odpravljanju napak</string> - <string name="show_next_up_when">Pokaži naslednjo</string> - <string name="show">Pokaži</string> - <string name="shuffle">Naključno</string> - <string name="skip">Preskoči</string> - <string name="sort_by_date_added">Datum dodajanja</string> - <string name="sort_by_date_episode_added">Datum dodanih epizod</string> - <string name="sort_by_date_played">Datum predvajanja</string> - <string name="sort_by_date_released">Datum izdaje</string> - <string name="sort_by_name">Imenu</string> - <string name="sort_by_random">Naključno</string> - <string name="studios">Studiji</string> - <string name="submit">Pošlji</string> - <string name="subtitle_download_too_long">Prenos traja dolgo, morda boste morali ponovno zagnati predvajanje</string> - <string name="subtitle">Podnapis</string> - <string name="subtitles">Podnapisi</string> - <string name="suggestions">Predlogi</string> - <string name="switch_servers">Zamenjaj strežnik</string> - <string name="switch_user">Preklopi</string> - <string name="top_unwatched">Najbolje ocenjeno, neogledano</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> - <string name="tv_season">Sezona</string> - <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> - <string name="updates">Novosti</string> - <string name="version">Različica</string> - <string name="video_scale">Povečava videa</string> - <string name="video">Video</string> - <string name="videos">Videji</string> - <string name="watch_live">Glej v živo</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> - <string name="aired_episode_order">Vrstni red predvajanih epizod</string> - <string name="create_playlist">Ustvari nov seznam predvajanja</string> - <string name="add_to_playlist">Dodaj na seznam predvajanja</string> - <string name="one_click_pause">Zaustavi z enim klikom</string> - <string name="one_click_pause_summary_on">Pritisni sredino smerne tipke za zaustavitev/predvajanje</string> - <string name="italic_font">Poševna pisava</string> - <string name="font">Vrsta pisave</string> - <string name="background">Ozadje</string> - <string name="success">Uspeh</string> - <string name="official_rating">Starševska ocena</string> - <string name="runtime_sort">Trajanje izvajanja</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_title">Dodaj %s med priljubljene</string> - <plurals name="downloads"> - <item quantity="one">%s prenos</item> - <item quantity="two">%s prenosa</item> - <item quantity="few">%s prenosi</item> - <item quantity="other">%s prenosov</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d ura</item> - <item quantity="two">%d uri</item> - <item quantity="few">%d ure</item> - <item quantity="other">%d ur</item> - </plurals> - <plurals name="days"> - <item quantity="one">%s dan</item> - <item quantity="two">%s dni</item> - <item quantity="few">%s dnevi</item> - <item quantity="other">%s dnevi</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d element</item> - <item quantity="two">%d elementa</item> - <item quantity="few">%d elementi</item> - <item quantity="other">%d elementov</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d sekunda</item> - <item quantity="two">%d sekundi</item> - <item quantity="few">%d sekunde</item> - <item quantity="other">%d sekund</item> - </plurals> - <plurals name="minutes"> - <item quantity="one">%s minuta</item> - <item quantity="two">%s minute</item> - <item quantity="few">%s minute</item> - <item quantity="other">%s minut</item> - </plurals> - <string name="ac3_supported">Naprava podpira AC3/Dolby Digital</string> - <string name="advanced_settings">Napredne nastavitve</string> - <string name="advanced_ui">Napredni uporabniški vmesnik</string> - <string name="app_theme">Tema aplikacije</string> - <string name="auto_check_for_updates">Samodejno preverjaj za posodobitve</string> - <string name="auto_play_next_delay">Časovni zamik pred predvajanjem naslednje epizode</string> - <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">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> - <string name="ffmpeg_extension_pref">Uporabi modul dekodirnika FFmpeg</string> - <string name="global_content_scale">Privzeto približanje videa</string> - <string name="install_update">Namesti posodobitev</string> - <string name="installed_version">Nameščena različica</string> - <string name="max_homepage_items">Največje število elementov v vrsticah na domači strani</string> - <string name="nav_drawer_pins_summary">Izberite privzete elemente za pokazat, ostali bodo skriti</string> - <string name="nav_drawer_pins">Prilagodite elemente v navigacijskem meniju</string> - <string name="nav_drawer_switch_on_focus_summary_off">Kliknite za prehod na drugo stran</string> - <string name="nav_drawer_switch_on_focus">Preklop strani v navigacijskem meniju ob osredotočenju</string> - <string name="pass_out_protection">Zaščita pred izklopom</string> - <string name="play_theme_music">Predvajaj uvodno glasbo</string> - <string name="remember_selected_tab">Zapomni si izbrane zavihke</string> - <string name="rewatch_next_up">Omogoči ponovno predvajanje v vrsti</string> - <string name="seek_bar_steps">Koraki za iskalno vrstico</string> - <string name="send_app_logs_summary">Koristno pri odpravljanju napak</string> - <string name="send_app_logs">Pošlji dnevnike aplikacije na trenutni strežnik</string> - <string name="send_crash_reports_summary">Poskusil bom poslati na zadnji povezani strežnik</string> - <string name="send_crash_reports">Pošlji poročila o napakah</string> - <string name="settings">Nastavitve</string> - <string name="skip_back_on_resume_preference">Preskoči nazaj ob nadaljevanju predvajanja</string> - <string name="skip_back_preference">Preskoči nazaj</string> - <string name="skip_commercials_behavior">Vedenje pri preskakovanju oglasov</string> - <string name="skip_forward_preference">Preskoči naprej</string> - <string name="skip_intro_behavior">Delovanje preskoka uvoda</string> - <string name="skip_outro_behavior">Delovanje preskoka konca</string> - <string name="skip_previews_behavior">Delovanje preskoka predogleda</string> - <string name="skip_recap_behavior">Delovanje preskoka povzetka</string> - <string name="update_available">Na voljo je posodobitev</string> - <string name="update_url_summary">URL za preverjanje razpoložljivosti posodobitev aplikacije</string> - <string name="update_url">URL za posodobitev</string> - <string name="font_size">Velikost pisave</string> - <string name="font_color">Barva pisave</string> - <string name="font_opacity">Prosojnost pisave</string> - <string name="edge_style">Slog robov</string> - <string name="edge_color">Barva robov</string> - <string name="background_opacity">Prosojnost ozadja</string> - <string name="background_style">Slog ozadja</string> - <string name="subtitle_style">Slog podnapisov</string> - <string name="background_color">Barva ozadja</string> - <string name="reset">Ponastavi</string> - <string name="bold_font">Krepka pisava</string> - <string name="player_backend">Zaledni del predvajalnika</string> - <string name="mpv_hardware_decoding">MPV: Use hardware decoding</string> - <string name="disable_if_crash">Onemogoči, če pride do zrušitev</string> - <string name="mpv_options">MPV nastavitve</string> - <string name="exoplayer_options">ExoPlayer nastavitve</string> - <string name="skip_segment_unknown">Preskoči segment</string> - <string name="skip_segment_commercial">Preskoči oglase</string> - <string name="skip_segment_preview">Preskoči predogled</string> - <string name="skip_segment_recap">Preskoči povzetek</string> - <string name="skip_segment_outro">Preskoči zaključek</string> - <string name="skip_segment_intro">Preskoči uvod</string> - <string name="played">Ogledano</string> - <string name="filter">Filter</string> - <string name="year">Leto</string> - <string name="decade">Desetletje</string> - <string name="remove">Odstrani</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Uporabi gpu-next</string> - <string name="mpv_conf">Uredi datoteko mpv.conf</string> - <string name="discard_change">Želiš zavrniti spremembe?</string> - <string name="subtitle_margin">Rob</string> - <string name="verbose_logging">Podrobno beleženje</string> - <string name="enter_pin">Vnesite PIN</string> - <string name="sign_in_auto">Samodejna prijava</string> - <string name="use_server_credentials">Prijava prek strežnika</string> - <string name="press_enter_to_confirm">Pritisnite sredinski gumb za potrditev</string> - <string name="require_pin_code">Zahtevaj PIN za profil</string> - <string name="confirm_pin">Potrdi PIN</string> - <string name="incorrect">Nepravilno</string> - <string name="pin_too_short">PIN mora imeti najmanj 4 številke</string> - <string name="will_remove_pin">Odstrani PIN</string> - <string name="image_cache_size">Velikost predpomnilnika za slike (MB)</string> - <string name="view_options">Možnosti prikaza</string> - <string name="columns">Stolpci</string> - <string name="spacing">Razmik</string> - <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">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> - <string name="automatic">Avtomatsko</string> - <string name="username_or_password">Uporabi uporabniško ime/geslo</string> - <string name="login">Prijava</string> - <string name="general">Splošno</string> - <string name="container">Kontejner</string> - <string name="title">Naslov</string> - <string name="codec">Kodek</string> - <string name="profile">Profil</string> - <string name="level">Level</string> - <string name="resolution">Resolucija</string> - <string name="anamorphic">Anamorfni</string> - <string name="interlaced">Interlaced</string> - <string name="framerate">Število sličic na sekundo</string> - <string name="bit_depth">Bitna globina</string> - <string name="video_range">Video razpon</string> - <string name="video_range_type">Vrsta video obsega</string> - <string name="color_space">Barvni prostor</string> - <string name="color_transfer">Prenos barv</string> - <string name="color_primaries">Osnovne barve</string> - <string name="pixel_format">Format pikslov</string> - <string name="ref_frames">Referenčni okvirji</string> - <string name="nal">NAL</string> - <string name="language">Jezik</string> - <string name="layout">Postavitev</string> - <string name="channels">Kanali</string> - <string name="sample_rate">Frekvenca vzorčenja</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">Pokaži naslove</string> - <string name="live_tv_repeat">Ponovi</string> - <string name="favorite_channels_at_beginning">Najprej prikaži priljubljene kanale</string> - <string name="sort_channels_recently_watched">Razvrsti kanale po zadnjih ogledih</string> - <string name="color_code_programs">Barvno označi programe</string> - <string name="subtitle_delay">Zakasnitev podnapisov</string> - <string name="backdrop_display">Slog ozadja</string> - <string name="resolution_switching">Preklapljanje ločljivosti</string> - <string name="local">Lokalno</string> - <string name="play_trailer">Predvajaj napovednik</string> - <string name="no_trailers">Ni napovednikov</string> - <string name="clear_track_choices">Izbriši izbrane skladbe</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">Neposredno predvajanje v formatu Dolby Vision Profile 7</string> - <string name="force_dovi_profile_7_summary">Preskoči preverjanje združljivosti naprav</string> - <string name="discover">Odkrij</string> - <string name="request">Zahteva</string> - <string name="pending">V postopku</string> - <string name="seerr_integration">Integracija Seerr</string> - <string name="remove_seerr_server">Odstrani strežnik Seerr</string> - <string name="seerr_server_added">Dodan strežnik Seerr</string> - <string name="quick_connect">Hitro povezava</string> - <string name="quick_connect_summary">Dovoli drugi napravi, da se prijavi v tvoj račun</string> - <string name="quick_connect_code">Vnesite kodo za hitro povezavo</string> - <string name="quick_connect_code_error">Koda mora imeti 6 številk</string> - <string name="quick_connect_success">Naprava je bila uspešno odobrena</string> - <string name="password">Geslo</string> - <string name="username">Uporabniško ime</string> - <string name="url">URL</string> - <string name="trending">V trendu</string> - <string name="upcoming_movies">Prihajajoči filmi</string> - <string name="upcoming_tv">Prihajajoče televizijske oddaje</string> - <string name="request_4k">Zahtevaj v 4K</string> - <string name="software_decoding_av1">Programsko dekodiranje AV1</string> - <string name="upgrade_mpv_toast">MPV je zdaj privzeti predvajalnik, razen za HDR.\nTo lahko spremenite v nastavitvah.</string> - <string name="hdr_subtitle_style">Slog podnapisov HDR</string> - <string name="image_subtitle_opacity">Prosojnost podnapisov</string> - <string name="brightness">Svetlost</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Nasičenost</string> - <string name="hue">Barvni ton</string> - <string name="red">Rdeča</string> - <string name="green">Zelena</string> - <string name="blue">Modra</string> - <string name="blur">Zameglitev</string> - <string name="save_for_album">Shrani v album</string> - <string name="play_slideshow">Predvajaj diaprojekcijo</string> - <string name="stop_slideshow">Ustavi predvajanje diaprojekcije</string> - <string name="slideshow_at_beginning">Na začetku</string> - <string name="no_more_images">Ni več fotografij</string> - <string name="rotate_left">Zavrti v levo</string> - <string name="rotate_right">Zavrti v desno</string> - <string name="zoom_in">Približaj</string> - <string name="zoom_out">Pomanjšaj</string> - <string name="slideshow_duration">Trajanje diaprojekcije</string> - <string name="play_videos_during_slideshow">Predvajaj videoposnetke med diaprojekcijo</string> - <string name="send_media_info_log_to_server">Pošlji dnevnik medijskih podatkov na strežnik</string> - <string name="no_limit">Brez omejitve</string> - <string name="max_days_next_up">Največje število dni v »Naslednjo«</string> - <string name="albums">Albumi</string> - <string name="artists">Umetniki</string> - <string name="songs">Glsabe</string> - <string name="go_to_artist">Pojdi na stran izvajalca</string> - <string name="now_playing">Trenutno predvajano</string> - <string name="instant_mix">Instantna mešanica</string> - <string name="go_to_album">Pojdi na album</string> - <string name="add_to_queue">Dodaj v čakalno vrsto</string> - <string name="add_row">Dodaj vrstico</string> - <string name="genres_in">Žanra v %1$s</string> - <string name="recently_released_in">Nedavno izdano v %1$s</string> - <string name="height">Višina</string> - <string name="apply_all_rows">Uporabi za vse vrstice</string> - <string name="customize_home">Prilagodi domačo stran</string> - <string name="home_rows">Vrstice v domači strani</string> - <string name="load_from_server">Naloži profil uporabnika s strežnika</string> - <string name="save_to_server">Shrani v profil uporabnika na strežniku</string> - <string name="load_from_web_client">Naloži iz spletnega odjemalca</string> - <string name="use_series">Uporabi sliko iz serije</string> - <string name="add_row_for">Dodaj vrstico za %1$s</string> - <string name="overwrite_server_settings">Želiš prepisati nastavitve na strežniku?</string> - <string name="overwrite_local_settings">Želiš prepisati lokalne nastavitve?</string> - <string name="for_episodes">Za epizode</string> - <string name="suggestions_for">Predlogi za %1$s</string> - <string name="increase_all_cards_size">Povečaj velikost vseh kartic</string> - <string name="decrease_all_cards_size">Zmanjšaj velikost vseh kartic</string> - <string name="use_thumb_images">Uporabi thumb slike</string> - <string name="settings_saved">Nastavitve shranjene</string> - <string name="display_presets">Prednastavitve zaslona</string> - <string name="display_presets_description">Vgrajene prednastavitve za hitro oblikovanje vseh vrstic</string> - <string name="customize_home_summary">Izberite vrstice in slike na domači strani</string> - <string name="start_after">Začni po</string> - <string name="animate">Animiraj</string> - <string name="max_age_rating">Najvišja starostna omejitev</string> - <string name="no_max">Brez maksimuma</string> - <string name="for_all_ages">Za vse starosti</string> - <string name="up_to_age">Do starosti %s</string> - <string name="screensaver">Ohranjevalnik zaslona</string> - <string name="screensaver_settings">Nastavitve ohranjevalnika zaslona</string> - <string name="start_screensaver">Zaženi ohranjevalnik zaslona</string> - <string name="duration">Trajanje</string> - <string name="photos">Slike</string> - <string name="include_types">Vključi vrste</string> - <string name="include_types_summary">Za katere vrste predmetov naj se prikažejo slike</string> - <string name="content_scale_fit">Fit</string> - <string name="content_scale_crop">Obreži</string> - <string name="content_scale_fill">Napolni</string> - <string name="content_scale_fill_width">Napolni širino</string> - <string name="content_scale_fill_height">Napolni višino</string> - <string name="display_preset_default">Privzete nastavitve Wholpin-a</string> - <string name="display_preset_compact">Kompakten Wholpin</string> - <string name="display_preset_series_thumb">Thumb slike TV serij</string> - <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> - <string name="volume_lowest">Najnižja</string> - <string name="volume_low">Nizko</string> - <string name="volume_medium">Srednje</string> - <string name="volume_high">Visoko</string> - <string name="volume_full">Polno</string> - <string name="purple">Vijolično</string> - <string name="orange">Oranžno</string> - <string name="bold_blue">Krepko modro</string> - <string name="black">Črno</string> - <string name="skip_ignore">Prezri</string> - <string name="skip_automatically">Samodejno preskoči</string> - <string name="skip_ask">Vprašaj, če prekoči</string> - <string name="ffmpeg_fallback">FFmpeg uporabi le, če ni na voljo vgrajenega dekodirnika</string> - <string name="ffmpeg_prefer">Raje uporabi FFmpeg kot vgrajene dekodirnike</string> - <string name="ffmpeg_never">Nikoli ne uporabljaj dekoderje FFmpeg</string> - <string name="next_up_playback_end">Na koncu predvajanja</string> - <string name="next_up_outro">Med zaključnimi napisi</string> - <string name="next_up_never">Nikoli</string> - <string name="white">Belo</string> - <string name="light_gray">Svetlo sivo</string> - <string name="dark_gray">Temno sivo</string> - <string name="yellow">Rumeno</string> - <string name="cyan">Cian</string> - <string name="magenta">Magenta</string> - <string name="subtitle_edge_outline">Oris</string> - <string name="subtitle_edge_shadow">Senca</string> - <string name="background_style_wrap">Ovij</string> - <string name="background_style_boxed">Boxed</string> - <string name="prefer_mpv">Raje izberi MPV</string> - <string name="external_player">Zunanji predvajalnik</string> - <string name="player_backend_options_subtitles_prefer_mpv">Za predvajanje HDR uporabite ExoPlayer</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">Kvadratno (1:1)</string> - <string name="image_type_primary">Primarno</string> - <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">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">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> - <string name="delete_item">Ali ste prepričani, da želite izbrisati ta element?</string> - <string name="show_media_management">Prikaži možnosti za upravljanje medijev</string> - <string name="search_for">Išči za %s</string> - <string name="actor">Igralec</string> - <string name="composer">Skladatelj</string> - <string name="writer">Pisatelj</string> - <string name="guest_star">Gostujoči igralec</string> - <string name="producer">Producent</string> - <string name="conductor">Dirigent</string> - <string name="lyricist">Avtor besedila</string> - <string name="arranger">Aranžer</string> - <string name="engineer">Inženir</string> - <string name="mixer">Mešalnik</string> - <string name="creator">Avtor</string> - <string name="artist">Umetnik</string> - <string name="album_artist">Izvajalec albuma</string> - <string name="album">Album</string> - <string name="popular_songs">Priljubljene glasbe</string> - <string name="play_next">Predvajaj naslednjo</string> - <string name="music_videos">Glasbeni videospoti</string> - <string name="lyrics">Besedilo</string> - <string name="hide_lyrics">Skrij besedilo</string> - <string name="show_lyrics">Pokaži besedilo</string> - <string name="song_has_lyrics">Glsaba ima besedilo</string> - <string name="remove_from_queue">Odstrani iz čakalne vrste</string> - <string name="show_album_cover">Pokaži naslovnico albuma</string> - <string name="show_visualizer">Prikaži vizualizator</string> - <string name="show_backdrop">Prikaži ozadje</string> - <string name="play_as_type">Predvajaj kot?</string> - <string name="visualizer_rationale">Za vizualizacijo zvoka je potrebno dovoljenje za snemanje zvoka.</string> - <string name="continue_string">Nadaljuj</string> - <string name="select_all">Izberi vse</string> - <string name="separate_types">Ločene vrste</string> - <string name="prefer_logos">Prikazuj logotipe za naslove</string> - <string name="updated_toast">Wolfskin posodobljen na %s</string> - <string name="remove_continue_watching">Odstrani iz nadaljevanja gledanja</string> - <string name="remove_next_up">Odstrani serijo iz nadaljevanja gledanja</string> - <string name="view_removed_next_up">Ogled serije, odstranjene iz naslednjega gledanja</string> - <string name="view_more">Poglej več</string> - <string name="discover_tv">Odkrijte TV oddaje</string> - <string name="discover_movies">Odkrijte filme</string> - <string name="studios_in">Studii v %1$s</string> - <string name="last_played">Nazadnje predvajano</string> - <string name="system_default">Privzete nastavitve sistema</string> - <string name="ass_subtitle_mode_libass">Neposredno predvajanje z libass</string> - <string name="ass_subtitle_mode_exoplayer">Neposredno predvajanje z vgrajenim predvajalnikom ExoPlayer</string> - <string name="ass_subtitle_mode_transcode">Pretvorba na strežniku</string> - <string name="ass_subtitle_playback">Predvajanje podnapisov SSA/ASS</string> - <string name="cinema_mode">Kino način</string> - <string name="show_more">Pokaži %1$s več</string> - <string name="skip_behavior">Delovanje preskokov</string> - <string name="skip_behavior_summary">Za uvodne napise, napise ob koncu filma itd.</string> -</resources> diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml deleted file mode 100644 index 2781d45b..00000000 --- a/app/src/main/res/values-sq/strings.xml +++ /dev/null @@ -1,36 +0,0 @@ -<?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-sr/strings.xml b/app/src/main/res/values-sr/strings.xml deleted file mode 100644 index 4244a0df..00000000 --- a/app/src/main/res/values-sr/strings.xml +++ /dev/null @@ -1,134 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">O aplikaciji</string> - <string name="active_recordings">Aktivni Snimci</string> - <string name="add_favorite">Omiljeni</string> - <string name="add_server">Dodaj Server</string> - <string name="add_user">Dodaj Korisnika</string> - <string name="audio">Audio</string> - <string name="birthplace">Mesto rođenja</string> - <string name="bitrate">Bitrate</string> - <string name="born">Rođen</string> - <string name="cancel_recording">Prekini Snimanje</string> - <string name="cancel_series_recording">Prekini Snimanje Serije</string> - <string name="cancel">Prekini</string> - <string name="chapters">Poglavlja</string> - <string name="choose_stream">Izaberi</string> - <string name="clear_image_cache">Očisti keš slika</string> - <string name="collection">Kolekcija</string> - <string name="collections">Kolekcije</string> - <string name="commercial">Reklama</string> - <string name="community_rating">Ocena Zajednice</string> - <string name="compose_error_message_title">Greška! Pritisni dugme da pošalješ logove serveru.</string> - <string name="confirm">Potvrdi</string> - <string name="continue_watching">Nastavi sa gledanjem</string> - <string name="critic_rating">Ocena Kritičara</string> - <string name="decimal_seconds">%.2f sekunde</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">Podrazumevano</string> - <string name="delete">Obriši</string> - <string name="died">Umro</string> - <string name="directed_by">Režirao %1$s</string> - <string name="director">Režiser</string> - <string name="disabled">Isključeno</string> - <string name="discovered_servers">Otkriveni Serveri</string> - <string name="download_and_update"><![CDATA[Preuzmi i ažuriraj]]></string> - <string name="downloading">Preuzimanje…</string> - <string name="enabled">Uključeno</string> - <string name="ends_at">Završava se u %1$s</string> - <string name="enter_server_url">Upiši Server IP ili URL</string> - <string name="enter_server_address">Upiši server adresu</string> - <string name="episodes">Epizode</string> - <string name="error_loading_collection">Greška pri učitavanju kolekcije %1$s</string> - <string name="external_track">Eksterni</string> - <string name="favorites">Omiljeni</string> - <string name="file_size">Veličina</string> - <string name="forced_track">Forsiran</string> - <string name="genres">Žanrovi</string> - <string name="go_to_series">Uđi u serije</string> - <string name="go_to">Idi do</string> - <string name="hide_controller_timeout">Sakrij kontrole za reprodukciju</string> - <string name="hide_debug_info">Sakrij debug informacije</string> - <string name="hide">Sakrij</string> - <string name="home">Kuća</string> - <string name="immediate">Odmah</string> - <string name="intro">Intro</string> - <string name="jump_letters">#ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ</string> - <string name="library">Biblioteka</string> - <string name="license_info">Informacije o licenci</string> - <string name="live_tv">TV Uživo</string> - <string name="loading">Učitavanje…</string> - <string name="mark_entire_series_as_played">Obeleži celu seriju kao odgledanu?</string> - <string name="mark_entire_series_as_unplayed">Obeleži celu seriju kao neodgledanu?</string> - <string name="mark_unwatched">Obeleži kao neodgledano</string> - <string name="mark_watched">Obeleži kao odgledano</string> - <string name="max_bitrate">Maks bitrate</string> - <string name="more_like_this">Slično ovome</string> - <string name="more">Još</string> - <string name="movies_title">Filmovi</string> - <string name="name">Ime</string> - <string name="next_up">Sledeće</string> - <string name="no_data">Nema podataka</string> - <string name="no_results">Nema rezultata</string> - <string name="no_servers_found">Server nije pronađen</string> - <string name="no_scheduled_recordings">Nema zakazanih snimaka</string> - <string name="no_update_available">Nema dostupnih ažuriranja</string> - <string name="none">Nijedan</string> - <string name="only_forced_subtitles">Samo Forsirani Titlovi</string> - <string name="outro">Outro</string> - <string name="path">Putanja</string> - <string name="people_title">Ljudi</string> - <string name="play_count">Broj Prikaza</string> - <string name="play_from_here">Pusti odavde</string> - <string name="play">Pusti</string> - <string name="playback_debug_info">Prikaži debug info reprodukcije</string> - <string name="playback_speed">Brzina Reprodukcije</string> - <string name="playback">Reprodukcija</string> - <string name="playlist">Plejlista</string> - <string name="playlists">Plejliste</string> - <string name="preview">Pregled</string> - <string name="profile_specific_settings">Korisnička Podešavanja</string> - <string name="profile_specific_settings_from_server">Korisnička podešavanja sa servera</string> - <string name="queue">Red</string> - <string name="recap">Rekapitulacija</string> - <string name="recently_added_in">Nedavno dodato u %1$s</string> - <string name="recently_added">Nedavno dodato</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 omiljenih</string> - <string name="restart">Restart</string> - <string name="resume">Nastavi</string> - <string name="save">Sačuvaj</string> - <string name="search_and_download"><![CDATA[Pretraži i Preuzmi]]></string> - <string name="search">Pretraži</string> - <string name="searching">Pretraga…</string> - <string name="voice_search">Glasovna pretraga</string> - <string name="voice_starting">Započinje</string> - <string name="voice_search_prompt">Pretraži govorom</string> - <string name="processing">Obrađuje se</string> - <string name="press_back_to_cancel">Pritisni nazad za prekid</string> - <string name="voice_error_audio">Greška u audio zapisu</string> - <string name="voice_error_client">Greška u prepoznavanju glasa</string> - <string name="voice_error_permissions">Potrebna je dozvola za mikrofon</string> - <string name="voice_error_network">Greška u mreži</string> - <string name="voice_error_network_timeout">Mreža izgubljena</string> - <string name="voice_error_no_match">Nije prepoznat govor</string> - <string name="voice_error_busy">Prepoznavanje govora zauzeto</string> - <string name="voice_error_server">Greška u Serveru</string> - <string name="voice_error_speech_timeout">Nije detektovan govor</string> - <string name="voice_error_unknown">Nepoznata greška</string> - <string name="voice_error_start_failed">Greška u pokretanju prepoznavanja glasa</string> - <string name="voice_error_timeout">Greška u prepoznavanju glasa</string> - <string name="retry">Pokušaj ponovo</string> - <string name="select_server">Odaberi Server</string> - <string name="select_user">Odaberi Korisnika</string> -</resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 002d5ece..41331e10 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_title">Filmer</string> + <string name="movies">Filmer</string> <string name="play">Spela</string> <string name="playlist">Spellista</string> <string name="playlists">Spellistor</string> @@ -73,15 +73,16 @@ <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">Förhandsvisningar</item> + <item quantity="other"></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_title">TV-serier</string> + <string name="tv_shows">TV-serier</string> <string name="ui_interface">Gränssnitt</string> <string name="unknown">Okänd</string> <string name="updates">Uppdateringar</string> @@ -90,10 +91,7 @@ <string name="video">Video</string> <string name="videos">Videor</string> <string name="watch_live">Titta live</string> - <plurals name="years_old"> - <item quantity="one">%1$d år gammal</item> - <item quantity="other">%1$d år gammal</item> - </plurals> + <string name="years_old">%1$d år gammal</string> <string name="play_with_transcoding">Spela med transkodning</string> <string name="show_clock">Visa klocka</string> <string name="aired_episode_order">Sändningsordning</string> @@ -107,50 +105,50 @@ <string name="success">Lyckades</string> <string name="official_rating">Åldersgräns</string> <string name="runtime_sort">Speltid</string> - <string name="extras_title">Extramaterial</string> + <string name="extras">Extramaterial</string> <plurals name="other_extras"> <item quantity="one">Annat</item> - <item quantity="other">Annat</item> + <item quantity="other"></item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bakom kulisserna</item> - <item quantity="other">Bakom kulisserna</item> + <item quantity="other"></item> </plurals> <plurals name="theme_songs"> <item quantity="one">Temalåtar</item> - <item quantity="other">Temalåtar</item> + <item quantity="other"></item> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideor</item> - <item quantity="other">Temavideor</item> + <item quantity="other"></item> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other">Klipp</item> + <item quantity="other"></item> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Borttagna scener</item> - <item quantity="other">Borttagna scener</item> + <item quantity="other"></item> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> - <item quantity="other">Intervjuer</item> + <item quantity="other"></item> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other">Scener</item> + <item quantity="other"></item> </plurals> <plurals name="samples"> <item quantity="one">Exempel</item> - <item quantity="other">Exempel</item> + <item quantity="other"></item> </plurals> <plurals name="featurettes"> <item quantity="one">Specialinslag</item> - <item quantity="other">Specialinslag</item> + <item quantity="other"></item> </plurals> <plurals name="shorts"> - <item quantity="one">Kortfilm</item> - <item quantity="other">Kortfilmer</item> + <item quantity="one">Kortfilmer</item> + <item quantity="other"></item> </plurals> <plurals name="downloads"> <item quantity="one">%s nedladdning</item> @@ -173,14 +171,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">%.2f sekunder</string> + <string name="decimal_seconds">%.1f 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</string> + <string name="enter_server_url">Ange serverns IP eller URL inklusive port</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> @@ -199,15 +197,15 @@ <string name="mark_unwatched">Markera som ospelad</string> <string name="mark_watched">Markera som spelad</string> <string name="max_bitrate">Maximal bithastighet</string> - <string name="more_like_this">Liknande titlar</string> - <string name="next_up">Nästa på tur</string> + <string name="more_like_this">Mer liknande detta</string> + <string name="next_up">Nästa upp</string> <string name="no_data">Ingen data</string> <string name="no_scheduled_recordings">Inga schemalagda inspelningar</string> <string name="no_update_available">Ingen uppdatering tillgänglig</string> <string name="none">Ingen</string> <string name="outro">Slutsekvens</string> <string name="path">Sökväg</string> - <string name="people_title">Medverkande</string> + <string name="people">Personer</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> @@ -217,7 +215,7 @@ <string name="ac3_supported">Enheten stöder AC3/Dolby Digital</string> <string name="advanced_settings">Avancerade inställningar</string> <string name="advanced_ui">Avancerat gränssnitt</string> - <string name="app_theme">Tema</string> + <string name="app_theme">App-tema</string> <string name="auto_check_for_updates">Kontrollera automatiskt efter uppdateringar</string> <string name="auto_play_next_delay">Fördröjning innan nästa uppspelning</string> <string name="auto_play_next">Spela upp nästa automatiskt</string> @@ -238,7 +236,7 @@ <string name="edge_style">Kantstil</string> <string name="edge_color">Kantfärg</string> <string name="background_opacity">Bakgrundsopacitet</string> - <string name="direct_play_ass">Använd libass för ASS-undertexter</string> + <string name="direct_play_ass">Direktuppspelning av ASS-undertexter</string> <string name="direct_play_pgs">Direktuppspelning av PGS-undertexter</string> <string name="downmix_stereo">Mixa alltid om till stereo</string> <string name="ffmpeg_extension_pref">Använd FFmpeg dekodermodul</string> @@ -246,449 +244,5 @@ <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">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> - <string name="skip_segment_commercial">Hoppa över reklam</string> - <string name="skip_segment_preview">Hoppa över förhandsvisning</string> - <string name="subtitle_style">Utseende på text</string> - <string name="background_color">Bakgrundsfärg</string> - <string name="played">Spelad</string> - <string name="filter">Filter</string> - <string name="confirm_pin">Konfirmera kod</string> - <string name="image_type">Bildtyp</string> - <string name="image_cache_size">Storlek på bilddiskcache (MB)</string> - <string name="view_options">Visa alternativ</string> - <string name="columns">Kolumner</string> - <string name="spacing">Avstånd</string> - <string name="aspect_ratio">Bildformat</string> - <string name="show_details">Visa detaljer</string> - <string name="guest_stars">Gästartister</string> - <string name="automatic">Automatisk</string> - <string name="username_or_password">Använd användarnamn/lösenord</string> - <string name="login">Logga in</string> - <string name="skip_forward_preference">Hoppa framåt</string> - <string name="reset">Återställ</string> - <string name="skip_segment_recap">Skippa sammanfattning</string> - <string name="year">År</string> - <string name="decade">Årtionde</string> - <string name="remove">Ta bort</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="subtitle_margin">Marginal</string> - <string name="enter_pin">Skriv in PIN</string> - <string name="sign_in_auto">Logga in automatiskt</string> - <string name="use_server_credentials">Logga in via server</string> - <string name="enter_server_address">Ange server-adress</string> - <string name="voice_error_network">Nätverksfel</string> - <string name="voice_error_unknown">Okänt fel</string> - <plurals name="minutes"> - <item quantity="one">%s minut</item> - <item quantity="other">%s minuter</item> - </plurals> - <string name="bold_font">Fet stil</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="discard_change">Avbryt ändringar?</string> - <string name="require_pin_code">Kräv PIN för profilen</string> - <string name="resolution">Upplösning</string> - <string name="language">Språk</string> - <string name="yes">Ja</string> - <string name="no">Nej</string> - <string name="live_tv_repeat">Upprepa</string> - <string name="password">Lösenord</string> - <string name="username">Användarnamn</string> - <string name="creator">Skapare</string> - <string name="engineer">Ingenjör</string> - <string name="actor">Skådespelare</string> - <string name="composer">Kompositör</string> - <string name="writer">Författare</string> - <string name="producer">Producent</string> - <string name="backdrop_style_image">Endast bild</string> - <string name="white">Vit</string> - <string name="light_gray">Ljusgrå</string> - <string name="dark_gray">Mörkgrå</string> - <string name="yellow">Gul</string> - <string name="purple">Lila</string> - <string name="black">Svart</string> - <string name="for_all_ages">För alla åldrar</string> - <string name="up_to_age">Upp till %s år</string> - <string name="screensaver">Skärmsläckare</string> - <string name="screensaver_settings">Inställningar Skärmsläckare</string> - <string name="start_screensaver">Starta skärmsläckare</string> - <string name="photos">Bilder</string> - <string name="settings_saved">Inställningar sparade</string> - <string name="rotate_left">Rotera vänster</string> - <string name="rotate_right">Rotera höger</string> - <string name="zoom_in">Zooma in</string> - <string name="zoom_out">Zooma ut</string> - <string name="no_limit">Ingen gräns</string> - <string name="add_row">Lägg till rad</string> - <string name="press_back_to_cancel">Tryck bakåt för att avbryta</string> - <string name="duration">Längd</string> - <string name="orange">Orange</string> - <string name="blue">Blå</string> - <string name="backdrop_style_dynamic">Bild med dynamisk färg</string> - <string name="background_style">Bakgrundsstil</string> - <string name="backdrop_display">Bakgrundsstil</string> - <string name="edge_size">Kantstorlek</string> - <string name="quick_connect_code_error">Koden måste vara 6 siffror</string> - <string name="quick_connect_code">Ange Quick Connect-kod</string> - <string name="quick_connect_success">Enheten har auktoriserats</string> - <string name="quick_connect_summary">Auktorisera en annan enhet att logga in på ditt konto</string> - <string name="recently_released_in">Nyligen släppt i %1$s</string> - <string name="green">Grön</string> - <string name="profile">Profil</string> - <string name="quick_connect">Snabbanslutning</string> - <string name="remove_seerr_server">Ta bort Seerr-server</string> - <string name="request_4k">Begär i 4K</string> - <string name="request">Begär</string> - <string name="remember_selected_tab">Kom ihåg vald flik</string> - <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 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> - <string name="nav_drawer_pins">Anpassa navigationsmenyn</string> - <string name="nav_drawer_pins_summary">Välj vilka objekt som ska visas som standard, övriga döljs</string> - <string name="nav_drawer_switch_on_focus">Växla sidor i navigationsmenyn vid fokus</string> - <string name="nav_drawer_switch_on_focus_summary_off">Klicka för att växla sida</string> - <string name="next_up_playback_end">Vid slutet av uppspelningen</string> - <string name="next_up_outro">Under eftertexter/slutsekvens</string> - <string name="pixel_format">Pixelformat</string> - <string name="pin_too_short">PIN måste vara minst 4 tecken</string> - <string name="overwrite_server_settings">Skriv över inställningar på servern?</string> - <string name="overwrite_local_settings">Skriv över lokala inställningar?</string> - <string name="seerr_integration">Seerr-integration</string> - <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</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> - <string name="search_for">Sök %s</string> - <string name="send_media_info_log_to_server">Skicka medieinformationslogg till server</string> - <string name="send_crash_reports_summary">Försöker skicka till senast anslutna server</string> - <string name="series_continueing">Pågående</string> - <string name="show_media_management">Visa alternativ för mediahantering</string> - <string name="show_titles">Visa titlar</string> - <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">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> - <string name="sample_rate">Samplingsfrekvens</string> - <string name="sample_rate_unit">Hz</string> - <string name="voice_error_server">Serverfel</string> - <string name="voice_error_speech_timeout">Inget tal upptäcktes</string> - <string name="voice_error_start_failed">Kunde inte starta röstigenkänning</string> - <string name="voice_error_timeout">Röstigenkänning tog för lång tid</string> - <string name="skip_commercials_behavior">Beteende för att hoppa över reklam</string> - <string name="skip_back_preference">Hoppa tillbaka</string> - <string name="skip_back_on_resume_preference">Hoppa tillbaka vid återupptagen uppspelning</string> - <string name="skip_ignore">Ignorera</string> - <string name="skip_intro_behavior">Beteende för att hoppa över intro</string> - <string name="skip_outro_behavior">Beteende för att hoppa över outro</string> - <string name="skip_previews_behavior">Beteende för att hoppa över förhandsvisningar</string> - <string name="skip_recap_behavior">Beteende för att hoppa över repris</string> - <string name="url">URL</string> - <string name="verbose_logging">Utförlig loggning</string> - <string name="upcoming_movies">Kommande filmer</string> - <string name="upcoming_tv">Kommande TV-serier</string> - <string name="update_url_summary">Webbadress som används för att kontrollera appuppdateringar</string> - <string name="upgrade_mpv_toast">MPV är nu standardspelaren förutom för HDR. Du kan ändra detta i inställningarna.</string> - <string name="use_series">Använd seriebild</string> - <string name="use_thumb_images">Använd miniatyrbilder</string> - <string name="home_rows">Rader på startsidan</string> - <string name="general">Allmänt</string> - <string name="guest_star">Gästskådespelare</string> - <string name="hdr_subtitle_style">HDR-undertextstil</string> - <string name="height">Höjd</string> - <string name="image_subtitle_opacity">Opacitet för bildundertexter</string> - <string name="hue">Nyans</string> - <string name="image_type_thumb">Miniatyr</string> - <string name="image_type_primary">Primär</string> - <string name="conductor">Dirigent</string> - <string name="container">Container</string> - <string name="content_scale_crop">Beskär</string> - <string name="content_scale_fill_height">Fyll höjd</string> - <string name="content_scale_fit">Anpassa</string> - <string name="content_scale_fill_width">Fyll bredd</string> - <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 radera detta objekt?</string> - <string name="discover">Upptäck</string> - <string name="display_preset_compact">Wolfskin Kompakt</string> - <string name="display_preset_default">Wolfskin 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">Wolfskin 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> - <string name="visualizer_rationale">Visualisering av ljuduppspelning kräver behörighet att spela in ljud.</string> - <string name="login_credentials_expired">Inloggningsuppgifter har löpt ut. Vänligen logga in på nytt.</string> - <string name="subtitle_mode">Undertextläge</string> - <string name="subtitle_mode_default">Standard</string> - <string name="subtitle_mode_smart">Smart</string> - <string name="subtitle_mode_only_forced">Endast tvingande</string> - <string name="subtitle_mode_always">Alltid</string> - <string name="subtitle_mode_none">Inga</string> - <string name="preferred_subtitle_language">Fördraget undertextspråk</string> - <string name="subtitles_hearing_impaired">För hörselskadade</string> - <string name="programs">TV-program</string> - <string name="recordings">Inspelningar</string> - <string name="folders_title">Mappar</string> - <string name="user_views">Användarvisningar</string> - <string name="genre_movies">%1$sfilmer</string> - <string name="quality_profile">Kvalitetsprofil</string> - <string name="hdr_formats">HDR-format</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="force_enable">Tvinga aktivering</string> - <string name="force_disable">Tvinga avaktivering</string> + <string name="download_and_update"><![CDATA[Ladda ner och uppdatera]]></string> </resources> diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml deleted file mode 100644 index 2ccccbae..00000000 --- a/app/src/main/res/values-th/strings.xml +++ /dev/null @@ -1,597 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">เกี่ยวกับแอปนี้</string> - <string name="active_recordings">การบันทึกที่ใช้งานอยู่</string> - <string name="add_favorite">ชื่นชอบ</string> - <string name="add_server">เพิ่มเซิร์ฟเวอร์</string> - <string name="add_user">เพิ่มผู้ใช้</string> - <string name="audio">เสียง</string> - <string name="birthplace">บ้านเกิด</string> - <string name="bitrate">บิตเรท</string> - <string name="born">เกิด</string> - <string name="cancel_recording">ยกเลิกการบันทึก</string> - <string name="cancel_series_recording">ยกเลิกการบันทึกซีรีส์</string> - <string name="cancel">ยกเลิก</string> - <string name="chapters">ฉาก</string> - <string name="choose_stream">เลือก %1$s</string> - <string name="clear_image_cache">ล้างแคชภาพ</string> - <string name="collection">คอลเลคชัน</string> - <string name="collections">คอลเลกชัน</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="critic_rating">การจัดอันดับนักวิจารณ์</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="disabled">ปิดใช้งาน</string> - <string name="discovered_servers">เซิร์ฟเวอร์ที่ค้นพบ</string> - <string name="download_and_update"><![CDATA[ดาวน์โหลดและอัปเดต]]></string> - <string name="downloading">กำลังดาวน์โหลด…</string> - <string name="enabled">ใช้งาน</string> - <string name="ends_at">จบที่เวลา %1$s</string> - <string name="enter_server_url">ป้อนที่อยู่ IP หรือ URL ของเซิร์ฟเวอร์</string> - <string name="enter_server_address">ป้อนที่อยู่เซิร์ฟเวอร์</string> - <string name="episodes">ตอน</string> - <string name="error_loading_collection">เกิดข้อผิดพลาดในการโหลดคอลเล็กชัน %1$s</string> - <string name="external_track">ภายนอก</string> - <string name="favorites">ชื่นชอบ</string> - <string name="file_size">ขนาด</string> - <string name="forced_track">บังคับ</string> - <string name="genres">แนวเนื้อหา</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> - <string name="home">หน้าหลัก</string> - <string name="immediate">ทันที</string> - <string name="intro">บทนำ</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">คลัง</string> - <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_unwatched">ทำเครื่องหมายว่ายังไม่ได้ดู</string> - <string name="mark_watched">ทำเครื่องหมายว่าดูแล้ว</string> - <string name="max_bitrate">บิตเรตสูงสุด</string> - <string name="more_like_this">เนื้อหาอื่นที่คล้ายกัน</string> - <string name="more">เพิ่มเติม</string> - <plurals name="movies"> - <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_servers_found">ไม่พบเซิร์ฟเวอร์</string> - <string name="no_scheduled_recordings">ไม่มีการบันทึกตามกำหนดเวลา</string> - <string name="no_update_available">ไม่มีการอัปเดต</string> - <string name="none">ไม่มี</string> - <string name="only_forced_subtitles">คำบรรยายแบบบังคับเท่านั้น</string> - <string name="outro">เอาท์โตร</string> - <string name="path">เส้นทาง</string> - <plurals name="people"> - <item quantity="other">ผู้คน</item> - </plurals> - <string name="play_count">จำนวนครั้งที่เล่น</string> - <string name="play_from_here">เล่นจากที่นี่</string> - <string name="play">เล่น</string> - <string name="playback_debug_info">แสดงข้อมูลการแก้ไขข้อผิดพลาดในการเล่น</string> - <string name="playback_speed">ความเร็วในการเล่น</string> - <string name="playback">การเล่น</string> - <string name="playlist">เพลย์ลิสต์</string> - <string name="playlists">เพลย์ลิสต์</string> - <string name="preview">ดูตัวอย่าง</string> - <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_recorded">บันทึกล่าสุด</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="restart">เริ่มใหม่</string> - <string name="resume">เล่นต่อ</string> - <string name="save">บันทึก</string> - <string name="search_and_download"><![CDATA[ค้นหาและดาวน์โหลด]]></string> - <string name="search">ค้นหา</string> - <string name="searching">ค้นหา…</string> - <string name="voice_search">ค้นหาด้วยเสียง</string> - <string name="voice_starting">กำลังเริ่มต้น</string> - <string name="voice_search_prompt">พูดเพื่อค้นหา</string> - <string name="processing">กำลังประมวลผล</string> - <string name="press_back_to_cancel">กดกลับเพื่อยกเลิก</string> - <string name="voice_error_audio">ข้อผิดพลาดในการบันทึกเสียง</string> - <string name="voice_error_client">ข้อผิดพลาดในการจดจำเสียง</string> - <string name="voice_error_permissions">ต้องได้รับอนุญาตจากไมโครโฟน</string> - <string name="voice_error_network">ข้อผิดพลาดของเครือข่าย</string> - <string name="voice_error_network_timeout">หมดเวลาเครือข่าย</string> - <string name="voice_error_no_match">ไม่มีคำพูดใดที่ได้รับการยอมรับ</string> - <string name="voice_error_busy">การจดจำเสียงไม่ว่าง</string> - <string name="voice_error_server">ข้อผิดพลาดของเซิร์ฟเวอร์</string> - <string name="voice_error_speech_timeout">ไม่พบคำพูด</string> - <string name="voice_error_unknown">ข้อผิดพลาดที่ไม่รู้จัก</string> - <string name="voice_error_start_failed">ไม่สามารถเริ่มการจดจำเสียงได้</string> - <string name="voice_error_timeout">การจดจำเสียงหมดเวลา</string> - <string name="retry">ทำซ้ำ</string> - <string name="select_server">เลือกเซิร์ฟเวอร์</string> - <string name="select_user">เลือกผู้ใช้งาน</string> - <string name="show_debug_info">แสดงข้อมูลการแก้ไขข้อบกพร่อง</string> - <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="sort_by_date_played">วันที่เล่น</string> - <string name="sort_by_date_released">วันที่เผยแพร่</string> - <string name="sort_by_name">ชื่อ</string> - <string name="sort_by_random">สุ่ม</string> - <string name="studios">สตูดิโอ</string> - <string name="submit">ยืนยัน</string> - <string name="subtitle_download_too_long">การดาวน์โหลดใช้เวลานาน คุณอาจต้องเริ่มเล่นใหม่</string> - <string name="subtitle">คำบรรยาย</string> - <string name="subtitles">คำบรรยาย</string> - <string name="suggestions">รายการแนะนำ</string> - <string name="switch_servers">สลับเซิร์ฟเวอร์</string> - <string name="switch_user">สวิตช์</string> - <string name="top_unwatched">รายการที่ได้คะแนนสูงที่ยังไม่ได้ดู</string> - <plurals name="trailers"> - <item quantity="other">ตัวอย่าง</item> - </plurals> - <string name="tv_dvr_schedule">กำหนดการบันทึก</string> - <string name="tv_guide">ผังรายการ</string> - <string name="tv_season">ซีซั่น</string> - <string name="tv_seasons">ซีซั่น</string> - <plurals name="tv_shows"> - <item quantity="other">รายการทีวี</item> - </plurals> - <string name="ui_interface">อินเทอร์เฟซ</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="watch_live">ชมสด</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_summary_on">กดปุ่ม D-Pad ตรงกลางเพื่อหยุด/เล่น</string> - <string name="italic_font">ตัวเอียง</string> - <string name="font">ฟอนต์</string> - <string name="background">พื้นหลัง</string> - <string name="success">สำเร็จ</string> - <string name="official_rating">การให้คะแนนโดยผู้ปกครอง</string> - <string name="runtime_sort">รันไทม์</string> - <string name="extras_title">เพิ่มเติม</string> - <plurals name="other_extras"> - <item quantity="other">อื่นๆ</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="other">เบื้องหลัง</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="other">เพลงธีม</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="other">วิดีโอธีม</item> - </plurals> - <plurals name="clips"> - <item quantity="other">คลิป</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="other">ฉากที่ถูกลบ</item> - </plurals> - <plurals name="interviews"> - <item quantity="other">สัมภาษณ์</item> - </plurals> - <plurals name="scenes"> - <item quantity="other">ซีน</item> - </plurals> - <plurals name="samples"> - <item quantity="other">ตัวอย่าง</item> - </plurals> - <plurals name="featurettes"> - <item quantity="other">สารคดี</item> - </plurals> - <plurals name="shorts"> - <item quantity="other">หนังสั้น</item> - </plurals> - <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> - <string name="app_theme">ธีมแอปพลิเคชัน</string> - <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="combine_continue_next"><![CDATA[รวมตัวเลือก \"ดูต่อ\" และ \"รายการถัดไป\" เข้าด้วยกัน]]></string> - <string name="direct_play_ass">เล่นคำบรรยาย ASS โดยตรงด้วย libass</string> - <string name="direct_play_pgs">เล่นคำบรรยาย PGS โดยตรง</string> - <string name="downmix_stereo">แปลงเสียงเป็นสเตอริโอเสมอ</string> - <string name="ffmpeg_extension_pref">ใช้โมดูลถอดรหัส FFmpeg</string> - <string name="global_content_scale">ขนาดเนื้อหาเริ่มต้น</string> - <string name="install_update">ติดตั้งอัปเดต</string> - <string name="installed_version">เวอร์ชันที่ติดตั้ง</string> - <string name="max_homepage_items">จำนวนรายการสูงสุดในแถวหน้าแรก</string> - <string name="nav_drawer_pins_summary">เลือกรายการเริ่มต้นที่จะแสดง รายการอื่นๆ จะถูกซ่อนไว้</string> - <string name="nav_drawer_pins">ปรับแต่งรายการเมนูนำทาง</string> - <string name="nav_drawer_switch_on_focus_summary_off">คลิกเพื่อสลับหน้า</string> - <string name="nav_drawer_switch_on_focus">สลับหน้าเมนูนำทางเมื่อโฟกัส</string> - <string name="pass_out_protection">การป้องกันการเผลอหลับ</string> - <string name="play_theme_music">เล่นเพลงธีม</string> - <string name="playback_overrides">การเล่นจะแทนที่</string> - <string name="remember_selected_tab">จำแท็บที่เลือก</string> - <string name="rewatch_next_up">เปิดใช้งานการรับชมซ้ำในครั้งต่อไป</string> - <string name="seek_bar_steps">ค้นหาขั้นบันได</string> - <string name="send_app_logs_summary">มีประโยชน์สำหรับการแก้ไขข้อผิดพลาด</string> - <string name="send_app_logs">ส่งบันทึกแอปไปยังเซิร์ฟเวอร์ปัจจุบัน</string> - <string name="send_crash_reports_summary">จะพยายามส่งไปยังเซิร์ฟเวอร์ที่เชื่อมต่อล่าสุด</string> - <string name="send_crash_reports">ส่งรายงานข้อขัดข้อง</string> - <string name="settings">ตั้งค่า</string> - <string name="skip_back_on_resume_preference">ย้อนกลับเมื่อกลับมาเล่นต่อ</string> - <string name="skip_back_preference">ข้ามกลับ</string> - <string name="skip_commercials_behavior">พฤติกรรมการข้ามโฆษณา</string> - <string name="skip_forward_preference">ข้ามไปข้างหน้า</string> - <string name="skip_intro_behavior">พฤติกรรมการข้ามช่วงเริ่มเรื่อง</string> - <string name="skip_outro_behavior">พฤติกรรมการข้ามช่วงท้ายเรื่อง</string> - <string name="skip_previews_behavior">พฤติกรรมการข้ามช่วงตัวอย่าง</string> - <string name="skip_recap_behavior">พฤติกรรมการข้ามช่วงสรุปตอนก่อนหน้า</string> - <string name="update_available">มีอัปเดตใหม่</string> - <string name="update_url_summary">URL ที่ใช้ตรวจสอบการอัปเดตแอป</string> - <string name="update_url">อัปเดต URL</string> - <string name="font_size">ขนาดตัวอักษร</string> - <string name="font_color">สีตัวอักษร</string> - <string name="font_opacity">ความทึบของตัวอักษร</string> - <string name="edge_style">รูปแบบขอบ</string> - <string name="edge_color">สีขอบ</string> - <string name="background_opacity">ความเข้มจางของพื้นหลัง</string> - <string name="background_style">รูปแบบพื้นหลัง</string> - <string name="subtitle_style">รูปแบบคำบรรยาย</string> - <string name="background_color">สีพื้นหลัง</string> - <string name="reset">รีเซ็ต</string> - <string name="bold_font">ตัวหนา</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> - <string name="exoplayer_options">ตัวเลือก ExoPlayer</string> - <string name="skip_segment_unknown">ข้ามส่วน</string> - <string name="skip_segment_commercial">ข้ามโฆษณา</string> - <string name="skip_segment_preview">ข้ามการแสดงตัวอย่าง</string> - <string name="skip_segment_recap">ข้ามสรุป</string> - <string name="skip_segment_outro">ข้ามเอาท์โทร</string> - <string name="skip_segment_intro">ข้ามบทนำ</string> - <string name="played">เล่นแล้ว</string> - <string name="filter">ตัวกรอง</string> - <string name="year">ปี</string> - <string name="decade">ทศวรรษ</string> - <string name="remove">ลบ</string> - <string name="dolby_vision">ดอลบี้ วิชั่น</string> - <string name="discard_change">ทิ้งการเปลี่ยนแปลง?</string> - <string name="subtitle_margin">ระยะห่างจากด้านล่าง</string> - <string name="verbose_logging">การบันทึกข้อมูลแบบละเอียด</string> - <string name="enter_pin">ป้อนรหัส PIN</string> - <string name="sign_in_auto">ลงชื่อเข้าใช้โดยอัตโนมัติ</string> - <string name="use_server_credentials">เข้าสู่ระบบผ่านเซิร์ฟเวอร์</string> - <string name="press_enter_to_confirm">กดตรงกลางเพื่อยืนยัน</string> - <string name="require_pin_code">ต้องใส่รหัส PIN สำหรับโปรไฟล์</string> - <string name="confirm_pin">ยืนยันรหัส PIN</string> - <string name="incorrect">ไม่ถูกต้อง</string> - <string name="pin_too_short">รหัส PIN ต้องมีอย่างน้อย 4 ปุ่ม</string> - <string name="will_remove_pin">จะลบ PIN</string> - <string name="image_cache_size">ขนาดแคชดิสก์รูปภาพ (เมกะไบต์)</string> - <string name="view_options">ตัวเลือกการดู</string> - <string name="columns">คอลัมน์</string> - <string name="spacing">ระยะห่าง</string> - <string name="aspect_ratio">อัตราส่วนภาพ</string> - <string name="show_details">แสดงรายละเอียด</string> - <string name="image_type">ประเภทรูปภาพ</string> - <string name="cast_and_crew"><![CDATA[นักแสดงและทีมงาน]]></string> - <string name="guest_stars">ดารารับเชิญ</string> - <string name="edge_size">ขนาดขอบ</string> - <string name="refresh_rate_switching">การสลับอัตราการรีเฟรช</string> - <string name="automatic">อัตโนมัติ</string> - <string name="username_or_password">ใช้ชื่อผู้ใช้/รหัสผ่าน</string> - <string name="login">เข้าสู่ระบบ</string> - <string name="general">ทั่วไป</string> - <string name="container">คอนเทนเนอร์</string> - <string name="title">ชื่อเรื่อง</string> - <string name="codec">Codec</string> - <string name="profile">โปรไฟล์</string> - <string name="level">เลเวล</string> - <string name="resolution">ความละเอียด</string> - <string name="anamorphic">อะนามอร์ฟิก</string> - <string name="interlaced">อินเทอร์เลซ</string> - <string name="framerate">อัตราเฟรม</string> - <string name="bit_depth">ความคมชัดสี</string> - <string name="video_range">ช่วงวิดีโอ</string> - <string name="video_range_type">ประเภทช่วงวิดีโอ</string> - <string name="color_space">พื้นที่สี</string> - <string name="color_transfer">การถ่ายโอนสี</string> - <string name="color_primaries">แม่สี</string> - <string name="pixel_format">รูปแบบพิกเซล</string> - <string name="ref_frames">เฟรมอ้างอิง</string> - <string name="nal">NAL</string> - <string name="language">ภาษา</string> - <string name="layout">รูปแบบ</string> - <string name="channels">ช่อง</string> - <string name="sample_rate">อัตราการสุ่มตัวอย่าง</string> - <string name="avc">AVC</string> - <string name="yes">ใช่</string> - <string name="no">ไม่</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">แสดงชื่อเรื่อง</string> - <string name="live_tv_repeat">ทำซ้ำ</string> - <string name="favorite_channels_at_beginning">แสดงช่องโปรดก่อน</string> - <string name="sort_channels_recently_watched">เรียงลำดับช่องตามลำดับการรับชมล่าสุด</string> - <string name="color_code_programs">โปรแกรมรหัสสี</string> - <string name="subtitle_delay">คำบรรยายล่าช้า</string> - <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="clear_track_choices">ล้างตัวเลือกแทร็ก</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">ทรูเอชดี</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="force_dovi_profile_7_summary">ไม่สนใจการตรวจสอบความเข้ากันได้ของอุปกรณ์</string> - <string name="discover">ค้นพบ</string> - <string name="request">คำขอ</string> - <string name="pending">รอดำเนินการ</string> - <string name="seerr_integration">การผสานรวม Seerr</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="password">รหัสผ่าน</string> - <string name="username">ชื่อผู้ใช้</string> - <string name="url">URL</string> - <string name="trending">เทรนด์</string> - <string name="upcoming_movies">ภาพยนตร์ที่จะเกิดขึ้น</string> - <string name="upcoming_tv">รายการทีวีที่จะออกอากาศเร็วๆ นี้</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="hdr_subtitle_style">รูปแบบคำบรรยาย HDR</string> - <string name="image_subtitle_opacity">ความทึบของคำบรรยายภาพ</string> - <string name="brightness">ความสว่าง</string> - <string name="contrast">คอนทราสต์</string> - <string name="saturation">ท้องอิ่ม</string> - <string name="hue">เนื้อสี</string> - <string name="red">แดง</string> - <string name="green">เขียว</string> - <string name="blue">น้ำเงิน</string> - <string name="blur">เบลอ</string> - <string name="save_for_album">บันทึกไว้ในอัลบั้ม</string> - <string name="play_slideshow">เล่นสไลด์โชว์</string> - <string name="stop_slideshow">หยุดสไลด์โชว์</string> - <string name="slideshow_at_beginning">เมื่อเริ่มต้น</string> - <string name="no_more_images">ไม่มีรูปถ่ายอีกต่อไป</string> - <string name="rotate_left">หมุนไปทางซ้าย</string> - <string name="rotate_right">หมุนไปทางขวา</string> - <string name="zoom_in">ซูมเข้า</string> - <string name="zoom_out">ซูมออก</string> - <string name="slideshow_duration">ระยะเวลาการแสดงภาพสไลด์</string> - <string name="play_videos_during_slideshow">เล่นวิดีโอระหว่างการนำเสนอสไลด์</string> - <string name="send_media_info_log_to_server">ส่งบันทึกข้อมูลสื่อไปยังเซิร์ฟเวอร์</string> - <string name="no_limit">ไม่มีขีดจำกัด</string> - <string name="max_days_next_up">จำนวนวันสูงสุดใน Next Up</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="add_row">เพิ่มแถว</string> - <string name="dolby_atmos">ดอลบี้ แอทมอส</string> - <string name="mpv_use_gpu_next">MPV: ใช้ gpu-next</string> - <string name="mpv_conf">แก้ไข mpv.conf</string> - <string name="genres_in">ประเภทใน %1$s</string> - <string name="recently_released_in">เพิ่งเปิดตัวใน %1$s</string> - <string name="height">ความสูง</string> - <string name="apply_all_rows">นำไปใช้กับทุกแถว</string> - <string name="customize_home">ปรับแต่งหน้าแรก</string> - <string name="home_rows">แถวที่หน้าแรก</string> - <string name="load_from_server">โหลดจากโปรไฟล์ผู้ใช้เซิร์ฟเวอร์</string> - <string name="save_to_server">ดาวน์โหลดจากโปรไฟล์ผู้ใช้คืนนี้</string> - <string name="load_from_web_client">โหลดจากเว็บไคลเอ็นต์</string> - <string name="use_series">ใช้ภาพของซีรีส์</string> - <string name="add_row_for">เพิ่มแถวสำหรับ %1$s</string> - <string name="overwrite_server_settings">ตั้งค่าทับบนเซิร์ฟเวอร์หรือไม่?</string> - <string name="overwrite_local_settings">แทนที่การตั้งค่าในเครื่องหรือไม่?</string> - <string name="for_episodes">สำหรับตอนต่างๆ</string> - <string name="suggestions_for">รายการแนะนำสำหรับ %1$s</string> - <string name="increase_all_cards_size">เพิ่มขนาดสำหรับการ์ดทุกใบ</string> - <string name="decrease_all_cards_size">ลดขนาดการ์ดทั้งหมด</string> - <string name="use_thumb_images">ใช้ภาพขนาดย่อ</string> - <string name="settings_saved">บันทึกการตั้งค่าแล้ว</string> - <string name="display_presets">แสดงค่าที่ตั้งไว้ล่วงหน้า</string> - <string name="display_presets_description">รูปแบบที่ตั้งไว้ล่วงหน้าเพื่อจัดรูปแบบได้อย่างรวดเร็ว</string> - <string name="customize_home_summary">เลือกแถวและรูปภาพบนหน้าแรก</string> - <string name="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">Wolfskin ค่าเริ่มต้น</string> - <string name="display_preset_compact">Wolfskin คอมแพค</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="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="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="backdrop_style_dynamic">ภาพที่มีสีแบบไดนามิก</string> - <string name="backdrop_style_image">รูปภาพเท่านั้น</string> - <string name="enter_url_api_key"><![CDATA[ป้อน URL และ API Key]]></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">Wolphin อัปเดตเวอร์ชั่นเป็น %s แล้ว</string> - <string name="remove_continue_watching">นำออกจากรายการดูต่อ</string> - <string name="remove_next_up">นำซีรีส์ออกจากรายการถัดไป</string> - <string name="play_with">เล่นโดย</string> - <string name="ass_subtitle_mode_libass">เล่นโดยตรงด้วย libass</string> - <string name="ass_subtitle_mode_exoplayer">เล่นโดยตรงด้วย ExoPlayer ในแอป</string> - <string name="next_up_never">ไม่เลย</string> - <string name="skip_behavior">พฤติกรรมการข้าม</string> - <string name="skip_behavior_summary">สำหรับช่วงเริ่มเรื่อง, เครดิต</string> - <string name="external_player">เครื่องเล่นภายนอก</string> - <string name="combined_search_results_off">แสดงผลลัพธ์ตามหมวดหมู่</string> - <string name="results">ผลลัพธ์</string> - <string name="combined_search_results_on">แสดงผลลัพธ์ในรายการเดียว</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="system_default">ค่าเริ่มต้นของระบบ</string> - <string name="ass_subtitle_mode_transcode">ฝังคำบรรยายลงในวิดีโอโดยการแปลงบนเซิร์ฟเวอร์</string> - <string name="ass_subtitle_playback">การแสดงคำบรรยาย SSA/ASS</string> - <string name="cinema_mode">รูปแบบโรงภาพยนตร์</string> - <string name="transcoding">แปลงไฟล์</string> - <plurals name="downloads"> - <item quantity="other">%s ดาวน์โหลด</item> - </plurals> - <plurals name="hours"> - <item quantity="other">%d ชั่วโมง</item> - </plurals> - <plurals name="days"> - <item quantity="other">%s วัน</item> - </plurals> - <plurals name="items"> - <item quantity="other">%d รายการ</item> - </plurals> - <plurals name="seconds"> - <item quantity="other">%d วินาที</item> - </plurals> - <plurals name="minutes"> - <item quantity="other">%s นาที</item> - </plurals> - <string name="combined_search_results">ผสานผลลัพธ์การค้นหา</string> - <string name="view_more">ดูเพิ่ม</string> - <string name="view_removed_next_up">ดูซีรี่ส์ที่ลบออกจาก ตอนถัดไป</string> - <string name="show_more">ดู %1$s เพิ่มเติม</string> - <string name="last_played">เล่นล่าสุด</string> - <string name="discover_tv">รายการทีวีที่ค้นพบ</string> - <string name="discover_movies">ภาพยนตร์ที่ค้นพบ</string> - <string name="studios_in">สตูดิโอของเรื่อง %1$s</string> -</resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml deleted file mode 100644 index 70804da5..00000000 --- a/app/src/main/res/values-tr/strings.xml +++ /dev/null @@ -1,762 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Hakkında</string> - <string name="active_recordings">Etkin kayıtlar</string> - <string name="add_favorite">Favori</string> - <string name="add_server">Sunucu Ekle</string> - <string name="add_user">Kullanıcı Ekle</string> - <string name="audio">Ses</string> - <string name="birthplace">Doğum yeri</string> - <string name="bitrate">Bit hızı</string> - <string name="born">Doğum Tarihi</string> - <string name="cancel_recording">Kaydı iptal et</string> - <string name="cancel_series_recording">Dizi kaydını iptal et</string> - <string name="cancel">İptal</string> - <string name="chapters">Bölümler</string> - <string name="choose_stream">%1$s ögesini seçin</string> - <string name="clear_image_cache">Resim önbelleğini temizle</string> - <string name="collection">Koleksiyon</string> - <string name="collections">Koleksiyonlar</string> - <string name="commercial">Reklam</string> - <string name="community_rating">Topluluk puanı</string> - <string name="compose_error_message_title">Bir hata oluştu! Günlükleri sunucunuza göndermek için düğmeye basın.</string> - <string name="confirm">Onayla</string> - <string name="continue_watching">İzlemeye devam et</string> - <string name="critic_rating">Eleştirmen puanı</string> - <string name="decimal_seconds">%.2f saniye</string> - <string name="default_track">Varsayılan</string> - <string name="delete">Sil</string> - <string name="died">Ölüm Tarihi</string> - <string name="directed_by">Yönetmen %1$s</string> - <string name="director">Yönetmen</string> - <string name="disabled">Devre dışı</string> - <string name="discovered_servers">Keşfedilen sunucular</string> - <string name="download_and_update"><![CDATA[İndir ve Güncelle]]></string> - <string name="downloading">İndiriliyor…</string> - <string name="enabled">Etkin</string> - <string name="ends_at">Bitiş saati %1$s</string> - <string name="enter_server_url">Sunucu IP adresini veya URL\'sini girin</string> - <string name="enter_server_address">Sunucu adresini girin</string> - <string name="episodes">Bölümler</string> - <string name="error_loading_collection">%1$s koleksiyonu yüklenirken bir hata oluştu</string> - <string name="external_track">Harici</string> - <string name="favorites">Favoriler</string> - <string name="file_size">Boyut</string> - <string name="forced_track">Zorunlu</string> - <string name="genres">Türler</string> - <string name="go_to_series">Dizilere git</string> - <string name="go_to">Git</string> - <string name="hide_controller_timeout">Oynatma kontrollerini gizle</string> - <string name="hide_debug_info">Hata ayıklama bilgilerini gizle</string> - <string name="hide">Gizle</string> - <string name="home">Ana Ekran</string> - <string name="immediate">Hemen</string> - <string name="intro">İntro</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Kütüphane</string> - <string name="license_info">Lisans bilgileri</string> - <string name="live_tv">Canlı TV</string> - <string name="loading">Yükleniyor…</string> - <string name="mark_entire_series_as_played">Tüm diziyi izlendi olarak işaretle?</string> - <string name="mark_entire_series_as_unplayed">Tüm diziyi izlenmedi olarak işaretle?</string> - <string name="mark_unwatched">İzlenmedi olarak işaretle</string> - <string name="mark_watched">İzlendi olarak işaretle</string> - <string name="max_bitrate">Maksimum bit hızı</string> - <string name="more_like_this">Benzerleri</string> - <string name="more">Daha Fazla</string> - <plurals name="movies"> - <item quantity="one">Film</item> - <item quantity="other">Filmler</item> - </plurals> - <string name="name">Ad</string> - <string name="next_up">Sıradaki bölümler</string> - <string name="no_data">Veri yok</string> - <string name="no_results">Sonuç bulunamadı</string> - <string name="no_servers_found">Sunucu bulunamadı</string> - <string name="no_scheduled_recordings">Planlanmış kayıt yok</string> - <string name="no_update_available">Güncelleme mevcut değil</string> - <string name="none">Hiçbiri</string> - <string name="only_forced_subtitles">Sadece zorunlu altyazılar</string> - <string name="outro">Kapanış jeneriği</string> - <string name="path">Yol</string> - <plurals name="people"> - <item quantity="one">Kişi</item> - <item quantity="other">Kişiler</item> - </plurals> - <string name="play_count">Oynatma Sayısı</string> - <string name="play_from_here">Buradan oynat</string> - <string name="play">Oynat</string> - <string name="playback_debug_info">Oynatma hata ayıklama bilgilerini göster</string> - <string name="playback_speed">Oynatma hızı</string> - <string name="playback">Oynatma</string> - <string name="playlist">Oynatma Listesi</string> - <string name="playlists">Oynatma Listeleri</string> - <string name="preview">Önizleme</string> - <string name="profile_specific_settings">Kullanıcı Profili Ayarları</string> - <string name="queue">Kuyruk</string> - <string name="recap">Özet</string> - <string name="recently_added_in">%1$s kütüphanesine son eklenenler</string> - <string name="recently_added">Son eklenen</string> - <string name="recently_recorded">Son Kaydedilenler</string> - <string name="recently_released">Son Çıkanlar</string> - <string name="recommended">Önerilenler</string> - <string name="record_program">Programı Kaydet</string> - <string name="record_series">Diziyi Kaydet</string> - <string name="remove_favorite">Favorilerden kaldır</string> - <string name="restart">Yeniden Başlat</string> - <string name="resume">Sürdür</string> - <string name="save">Kaydet</string> - <string name="search_and_download"><![CDATA[Ara ve İndir]]></string> - <string name="search">Ara</string> - <string name="searching">Aranıyor…</string> - <string name="voice_search">Sesli arama</string> - <string name="voice_starting">Başlatılıyor</string> - <string name="voice_search_prompt">Aramak için konuşun</string> - <string name="processing">İşleniyor</string> - <string name="press_back_to_cancel">İptal etmek için geri tuşuna basın</string> - <string name="voice_error_audio">Ses kayıt hatası</string> - <string name="voice_error_client">Ses tanıma hatası</string> - <string name="voice_error_permissions">Mikrofon izni gerekiyor</string> - <string name="voice_error_network">Ağ hatası</string> - <string name="voice_error_network_timeout">Ağ zaman aşımı</string> - <string name="voice_error_no_match">Ses tanınmadı</string> - <string name="voice_error_busy">Ses tanıma meşgul</string> - <string name="voice_error_server">Sunucu hatası</string> - <string name="voice_error_speech_timeout">Ses algılanamadı</string> - <string name="voice_error_unknown">Bilinmeyen hata</string> - <string name="voice_error_start_failed">Ses tanıma başlatılamadı</string> - <string name="voice_error_timeout">Ses tanıma zaman aşımına uğradı</string> - <string name="retry">Yeniden deneyin</string> - <string name="select_server">Sunucu Seç</string> - <string name="select_user">Kullanıcı Seç</string> - <string name="show_debug_info">Hata ayıklama bilgisini göster</string> - <string name="show_next_up_when">Sıradakini göster</string> - <string name="show">Göster</string> - <string name="shuffle">Karıştır</string> - <string name="skip">Atla</string> - <string name="sort_by_date_added">Eklenme tarihi</string> - <string name="sort_by_date_episode_added">Bölüm Eklenme Tarihi</string> - <string name="sort_by_date_played">Oynatma Tarihi</string> - <string name="sort_by_date_released">Yayınlanma Tarihi</string> - <string name="sort_by_name">Ad</string> - <string name="sort_by_random">Rastgele</string> - <string name="studios">Yapım Stüdyoları</string> - <string name="submit">Gönder</string> - <string name="subtitle_download_too_long">İndirme işlemi uzun sürüyor, oynatmayı yeniden başlatmanız gerekebilir</string> - <string name="subtitle">Altyazı</string> - <string name="subtitles">Altyazılar</string> - <string name="suggestions">Öneriler</string> - <string name="switch_servers">Sunucu değiştir</string> - <string name="switch_user">Değiştir</string> - <string name="top_unwatched">İzlenmemiş En İyiler</string> - <plurals name="trailers"> - <item quantity="one">Fragman</item> - <item quantity="other">Fragmanlar</item> - </plurals> - <string name="tv_dvr_schedule">Kayıt Takvimi</string> - <string name="tv_guide">Rehber</string> - <string name="tv_season">Sezon</string> - <string name="tv_seasons">Sezonlar</string> - <plurals name="tv_shows"> - <item quantity="one">Dizi</item> - <item quantity="other">Diziler</item> - </plurals> - <string name="ui_interface">Arayüz</string> - <string name="unknown">Bilinmiyor</string> - <string name="updates">Güncellemeler</string> - <string name="version">Sürüm</string> - <string name="video_scale">Video Ölçeği</string> - <string name="video">Video</string> - <string name="videos">Videolar</string> - <string name="watch_live">Canlı izle</string> - <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> - <string name="aired_episode_order">Yayınlanma Sırası</string> - <string name="create_playlist">Yeni oynatma listesi oluştur</string> - <string name="add_to_playlist">Oynatma listesine ekle</string> - <string name="one_click_pause">Tek tıkla duraklat</string> - <string name="one_click_pause_summary_on">Durdur/Oynat için orta tuşa basın</string> - <string name="italic_font">Metni italik yap</string> - <string name="font">Font</string> - <string name="background">Arka plan</string> - <string name="success">Başarılı</string> - <string name="official_rating">Yaş Derecelendirmesi</string> - <string name="runtime_sort">Süre</string> - <string name="extras_title">Özel Özellikler</string> - <plurals name="other_extras"> - <item quantity="one">Diğer</item> - <item quantity="other">Diğer</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="one">Sahne Arkası</item> - <item quantity="other">Sahne Arkaları</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="one">Tema şarkısı</item> - <item quantity="other">Tema şarkıları</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="one">Tema Videosu</item> - <item quantity="other">Tema Videoları</item> - </plurals> - <plurals name="clips"> - <item quantity="one">Klip</item> - <item quantity="other">Klipler</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="one">Silinmiş Sahne</item> - <item quantity="other">Silinmiş Sahneler</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Röportaj</item> - <item quantity="other">Röportajlar</item> - </plurals> - <plurals name="scenes"> - <item quantity="one">Sahne</item> - <item quantity="other">Sahneler</item> - </plurals> - <plurals name="samples"> - <item quantity="one">Örnek</item> - <item quantity="other">Örnekler</item> - </plurals> - <plurals name="featurettes"> - <item quantity="one">Özel Seçenek</item> - <item quantity="other">Özel Seçenekler</item> - </plurals> - <plurals name="shorts"> - <item quantity="one">Kısa Video</item> - <item quantity="other">Kısa Videolar</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s indirme</item> - <item quantity="other">%s indirilenler</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d saat</item> - <item quantity="other">%d saat</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d öğe</item> - <item quantity="other">%d öğeler</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d saniye</item> - <item quantity="other">%d saniye</item> - </plurals> - <string name="ac3_supported">AC3/Dolby Digital desteği</string> - <string name="advanced_settings">Gelişmiş Ayarlar</string> - <string name="advanced_ui">Gelişmiş Arayüz</string> - <string name="app_theme">Uygulama teması</string> - <string name="auto_check_for_updates">Güncellemeleri otomatik denetle</string> - <string name="auto_play_next_delay">Sıradakini oynatma gecikmesi</string> - <string name="auto_play_next">Sıradakini otomatik oynat</string> - <string name="check_for_updates">Güncellemeleri kontrol et</string> - <string name="combine_continue_next_summary">Yalnızca diziler için geçerlidir</string> - <string name="combine_continue_next"><![CDATA[İzlemeye Devam Et ve Sıradaki Bölümleri Birleştir]]></string> - <string name="direct_play_ass">ASS altyazıları için libass kullan</string> - <string name="direct_play_pgs">PGS altyazıları doğrudan oynat</string> - <string name="downmix_stereo">Her zaman Stereo\'ya dönüştür</string> - <string name="ffmpeg_extension_pref">FFmpeg kod çözücü kullan</string> - <string name="global_content_scale">Varsayılan içerik ölçeği</string> - <string name="install_update">Güncellemeyi yükle</string> - <string name="installed_version">Yüklü sürüm</string> - <string name="max_homepage_items">Ana sayfa satırlarında maksimum öge sayısı</string> - <string name="nav_drawer_pins_summary">Gösterilecek varsayılan ögeleri seçin, diğerleri gizlenecektir</string> - <string name="nav_drawer_pins">Yan Menü Ögelerini Düzenle</string> - <string name="nav_drawer_switch_on_focus_summary_off">Sayfalar arası geçiş için tıklayın</string> - <string name="nav_drawer_switch_on_focus">Odaklanınca yan menü sayfalarını değiştir</string> - <string name="pass_out_protection">Uyuyakalma Koruması</string> - <string name="play_theme_music">Tema müziğini oynat</string> - <string name="playback_overrides">Oynatmayı geçersiz kılanlar</string> - <string name="remember_selected_tab">Seçili sekmeleri hatırla</string> - <string name="rewatch_next_up">Sıradaki bölümlerde tekrar izlemeyi etkinleştir</string> - <string name="seek_bar_steps">İlerleme çubuğu adımları</string> - <string name="send_app_logs_summary">Hata ayıklama için yararlıdır</string> - <string name="send_app_logs">Uygulama günlüklerini mevcut sunucuya gönder</string> - <string name="send_crash_reports_summary">Son bağlanan sunucuya gönderilmeye çalışılacak</string> - <string name="send_crash_reports">Hata raporlarını gönder</string> - <string name="settings">Ayarlar</string> - <string name="skip_back_on_resume_preference">Devam ederken videoyu geri sar</string> - <string name="skip_back_preference">Geri atla</string> - <string name="skip_commercials_behavior">Reklam geçme davranışı</string> - <string name="skip_forward_preference">İleri atla</string> - <string name="skip_intro_behavior">İntro atlama davranışı</string> - <string name="skip_outro_behavior">Kapanış jeneriği atlama davranışı</string> - <string name="skip_previews_behavior">Önizlemeyi atlama davranışı</string> - <string name="skip_recap_behavior">Özet atlama davranışı</string> - <string name="update_available">Güncelleme mevcut</string> - <string name="update_url_summary">Uygulama güncellemelerini kontrol etmek için kullanılan URL</string> - <string name="update_url">Güncelleme URL\'si</string> - <string name="font_size">Yazı tipi boyutu</string> - <string name="font_color">Yazı tipi rengi</string> - <string name="font_opacity">Yazı tipi opaklığı</string> - <string name="edge_style">Kenar stili</string> - <string name="edge_color">Kenar rengi</string> - <string name="background_opacity">Arka plan opaklığı</string> - <string name="background_style">Arka plan stili</string> - <string name="subtitle_style">Altyazı stili</string> - <string name="background_color">Arka plan rengi</string> - <string name="reset">Sıfırla</string> - <string name="bold_font">Kalın yazı tipi</string> - <string name="player_backend">Oynatıcı motoru</string> - <string name="mpv_hardware_decoding">MPV: Donanım kod çözmeyi kullan</string> - <string name="disable_if_crash">Çökme sorunu yaşıyorsanız devre dışı bırakın</string> - <string name="mpv_options">MPV Ayarları</string> - <string name="exoplayer_options">ExoPlayer Ayarları</string> - <string name="skip_segment_unknown">Bölümü atla</string> - <string name="skip_segment_commercial">Reklamları atla</string> - <string name="skip_segment_preview">Önizlemeyi atla</string> - <string name="skip_segment_recap">Özeti atla</string> - <string name="skip_segment_outro">Kapanış jeneriğini atla</string> - <string name="skip_segment_intro">İntroyu atla</string> - <string name="played">Oynatıldı</string> - <string name="filter">Filtre</string> - <string name="year">Yıl</string> - <string name="decade">Dönem</string> - <string name="remove">Sil</string> - <string name="mpv_use_gpu_next">MPV: gpu-next kullan</string> - <string name="mpv_conf">mpv.conf dosyasını düzenle</string> - <string name="discard_change">Değişiklikleri iptal et?</string> - <string name="subtitle_margin">Kenar boşluğu</string> - <string name="verbose_logging">Ayrıntılı günlükleme</string> - <string name="enter_pin">PIN girin</string> - <string name="sign_in_auto">Otomatik oturum aç</string> - <string name="use_server_credentials">Sunucu üzerinden giriş yap</string> - <string name="press_enter_to_confirm">Onaylamak için orta tuşa basın</string> - <string name="require_pin_code">Profil için PIN iste</string> - <string name="confirm_pin">PIN\'i onayla</string> - <string name="incorrect">Hatalı</string> - <string name="pin_too_short">PIN en az 4 haneli olmalıdır</string> - <string name="will_remove_pin">PIN kaldırılacak</string> - <string name="image_cache_size">Görüntü disk önbellek boyutu (MB)</string> - <string name="view_options">Görünüm seçenekleri</string> - <string name="columns">Sütun sayısı</string> - <string name="spacing">Öğe aralığı</string> - <string name="aspect_ratio">En Boy Oranı</string> - <string name="show_details">Detayları göster</string> - <string name="image_type">Görsel türü</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="cast_and_crew"><![CDATA[Oyuncular ve Ekip]]></string> - <string name="guest_stars">Konuk oyuncular</string> - <string name="edge_size">Kenar boyutu</string> - <string name="refresh_rate_switching">Yenileme hızı değiştirme</string> - <string name="automatic">Otomatik</string> - <string name="username_or_password">Kullanıcı adı/şifre kullan</string> - <string name="login">Oturum aç</string> - <string name="general">Genel</string> - <string name="container">Kapsayıcı</string> - <string name="title">Başlık</string> - <string name="codec">Codec</string> - <string name="profile">Profil</string> - <string name="level">Düzey</string> - <string name="resolution">Çözünürlük</string> - <string name="anamorphic">Anamorfik</string> - <string name="interlaced">Geçmeli</string> - <string name="framerate">Kare hızı</string> - <string name="bit_depth">Bit derinliği</string> - <string name="video_range">Video aralığı</string> - <string name="video_range_type">Video aralık türü</string> - <string name="color_space">Renk alanı</string> - <string name="color_transfer">Renk aktarımı</string> - <string name="color_primaries">Renk ön seçimleri</string> - <string name="pixel_format">Piksel biçimi</string> - <string name="ref_frames">Referans kareler</string> - <string name="nal">NAL</string> - <string name="language">Dil</string> - <string name="layout">Düzen</string> - <string name="channels">Kanallar</string> - <string name="sample_rate">Örnekleme hızı</string> - <string name="avc">AVC</string> - <string name="yes">Evet</string> - <string name="no">Hayır</string> - <string name="sdr">SDR</string> - <string name="hdr">HDR</string> - <string name="hdr10">HDR10</string> - <string name="hdr10_plus">HDR10+</string> - <string name="hlg">HLG</string> - <string name="bit_unit">bit</string> - <string name="sample_rate_unit">Hz</string> - <string name="show_titles">Başlıkları göster</string> - <string name="live_tv_repeat">Tekrarla</string> - <string name="favorite_channels_at_beginning">Favori kanalları en başta göster</string> - <string name="sort_channels_recently_watched">Kanalları son izlenene göre sırala</string> - <string name="color_code_programs">Programları türüne göre renklendir</string> - <string name="subtitle_delay">Altyazı gecikmesi</string> - <string name="backdrop_display">Görsel arka plan stili</string> - <string name="resolution_switching">Çözünürlük değiştirme</string> - <string name="local">Lokal</string> - <string name="play_trailer">Fragmanı oynat</string> - <string name="no_trailers">Fragman yok</string> - <string name="clear_track_choices">Parça seçimlerini temizle</string> - <string name="dts_x">DTS:X</string> - <string name="dts_hd">DTS:HD</string> - <string name="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 Profil 7\'yi doğrudan oynat</string> - <string name="force_dovi_profile_7_summary">Cihaz uyumluluk kontrollerini yoksay</string> - <string name="discover">Keşfet</string> - <string name="request">İstek</string> - <string name="pending">Beklemede</string> - <string name="seerr_integration">Seerr entegrasyonu</string> - <string name="remove_seerr_server">Seerr Sunucusunu Kaldır</string> - <string name="seerr_server_added">Seerr sunucusu eklendi</string> - <string name="password">Şifre</string> - <string name="username">Kullanıcı adı</string> - <string name="url">URL</string> - <string name="trending">Popüler</string> - <string name="upcoming_movies">Yaklaşan Filmler</string> - <string name="upcoming_tv">Yaklaşan Diziler</string> - <string name="request_4k">4K olarak iste</string> - <string name="software_decoding_av1">AV1 yazılımsal kod çözme</string> - <string name="upgrade_mpv_toast">HDR içerikler hariç varsayılan oynatıcı artık MPV. \nBu ayarı ayarlar bölümünden değiştirebilirsiniz.</string> - <string name="hdr_subtitle_style">HDR Altyazı Stili</string> - <string name="image_subtitle_opacity">Altyazı Arka Plan Matlığı</string> - <string name="brightness">Parlaklık</string> - <string name="contrast">Kontrast</string> - <string name="saturation">Doygunluk</string> - <string name="hue">Ton</string> - <string name="red">Kırmızı</string> - <string name="green">Yeşil</string> - <string name="blue">Mavi</string> - <string name="blur">Bulanıklık</string> - <string name="save_for_album">Albüme Kaydet</string> - <string name="play_slideshow">Slayt Gösterisini Oynat</string> - <string name="stop_slideshow">Slayt Gösterisini Durdur</string> - <string name="slideshow_at_beginning">En baştan</string> - <string name="no_more_images">Daha fazla fotoğraf yok</string> - <string name="rotate_left">Sola döndür</string> - <string name="rotate_right">Sağa döndür</string> - <string name="zoom_in">Yakınlaştır</string> - <string name="zoom_out">Uzaklaştır</string> - <string name="slideshow_duration">Slayt Gösterisi Süresi</string> - <string name="play_videos_during_slideshow">Slayt gösterisi sırasında videoları oynat</string> - <plurals name="days"> - <item quantity="one">%s gün</item> - <item quantity="other">%s günler</item> - </plurals> - <string name="send_media_info_log_to_server">Medya bilgisini sunucuya gönder</string> - <string name="no_limit">Sınırsız</string> - <string name="max_days_next_up">Sıradaki bölümlerde kalacağı gün sayısı</string> - <string name="quick_connect">Hızlı Bağlan</string> - <string name="quick_connect_summary">Başka bir cihazın giriş yapmasına izin ver</string> - <string name="quick_connect_code">Hızlı Bağlan kodunu girin</string> - <string name="quick_connect_code_error">Kod 6 haneli olmalıdır</string> - <string name="quick_connect_success">Cihaz başarıyla yetkilendirildi</string> - <string name="add_row">Satır ekle</string> - <string name="genres_in">%1$s içindeki türler</string> - <string name="recently_released_in">%1$s kütüphanesinde son çıkanlar</string> - <string name="height">Yükseklik</string> - <string name="apply_all_rows">Tüm satırlara uygula</string> - <string name="customize_home">Ana ekranı özelleştir</string> - <string name="home_rows">Ana ekran satırları</string> - <string name="load_from_server">Kullanıcı profilinden yükle</string> - <string name="save_to_server">Kullanıcı profiline kaydet</string> - <string name="load_from_web_client">Web istemcisinden yükle</string> - <string name="use_series">Dizi görselini kullan</string> - <string name="add_row_for">%1$s için satır ekle</string> - <string name="overwrite_server_settings">Sunucudaki ayarların üzerine yazılsın mı?</string> - <string name="overwrite_local_settings">Yerel ayarların üzerine yazılsın mı?</string> - <string name="for_episodes">Bölümler için</string> - <string name="suggestions_for">%1$s için tavsiyeler</string> - <string name="increase_all_cards_size">Tüm kartların boyutunu artır</string> - <string name="decrease_all_cards_size">Tüm kartların boyutunu azalt</string> - <string name="use_thumb_images">Küçük resimleri kullan</string> - <string name="settings_saved">Ayarlar kaydedildi</string> - <string name="display_presets">Görüntü ön ayarları</string> - <string name="display_presets_description">Tüm satırları hızlıca stilize etmek için hazır ayarlar</string> - <string name="favorite_items_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> - <string name="content_scale_fill">Doldur</string> - <string name="content_scale_fill_width">Genişliği Doldur</string> - <string name="content_scale_fill_height">Yüksekliği Doldur</string> - <string name="display_preset_default">Wolfskin Varsayılan</string> - <string name="display_preset_compact">Wolfskin Kompakt</string> - <string name="display_preset_series_thumb">Dizi Küçük Resimleri</string> - <string name="display_preset_episode_thumbnails">Bölüm Küçük Resimleri</string> - <string name="volume_lowest">En Düşük</string> - <string name="volume_low">Düşük</string> - <string name="volume_medium">Orta</string> - <string name="volume_high">Yüksek</string> - <string name="volume_full">Tam</string> - <string name="purple">Mor</string> - <string name="orange">Turuncu</string> - <string name="bold_blue">Koyu Mavi</string> - <string name="black">Siyah</string> - <string name="skip_ignore">Yoksay</string> - <string name="skip_automatically">Otomatik Atla</string> - <string name="skip_ask">Atlamak için sor</string> - <string name="ffmpeg_fallback">Dahili çözücü yoksa FFmpeg kullan</string> - <string name="ffmpeg_prefer">Dahili çözücüler yerine FFmpeg\'i tercih et</string> - <string name="ffmpeg_never">FFmpeg çözücülerini asla kullanma</string> - <string name="next_up_playback_end">Oynatma sonunda</string> - <string name="next_up_outro">Kapanış bölümünde / Jenerik sırasında</string> - <string name="white">Beyaz</string> - <string name="light_gray">Açık Gri</string> - <string name="dark_gray">Koyu Gri</string> - <string name="yellow">Sarı</string> - <string name="cyan">Camgöbeği</string> - <string name="magenta">Macenta</string> - <string name="subtitle_edge_outline">Kenarlık</string> - <string name="subtitle_edge_shadow">Gölge</string> - <string name="background_style_wrap">Kapla</string> - <string name="background_style_boxed">Kutu içinde</string> - <string name="prefer_mpv">MPV\'yi tercih et</string> - <string name="player_backend_options_subtitles_prefer_mpv">HDR oynatma için ExoPlayer kullan</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">Kare (1:1)</string> - <string name="image_type_primary">Birincil</string> - <string name="image_type_thumb">Küçük Resim</string> - <string name="backdrop_style_dynamic">Dinamik renkli görsel</string> - <string name="backdrop_style_image">Sadece görsel</string> - <string name="enter_url_api_key"><![CDATA[URL ve API Anahtarını Girin]]></string> - <string name="api_key">API Anahtarı</string> - <string name="seerr_login">Seerr sunucusuna giriş yap</string> - <string name="seerr_jellyfin_user">Jellyfin kullanıcısı</string> - <string name="seerr_local_user">Yerel kullanıcı</string> - <string name="search_and_download_subtitles"><![CDATA[Altyazı ara ve indir]]></string> - <string name="no_subtitles_found">Uzak altyazı bulunamadı</string> - <string name="series_continueing">Günümüz</string> - <plurals name="minutes"> - <item quantity="one">%s dakika</item> - <item quantity="other">%s dakika</item> - </plurals> - <string name="start_after">Başlama süresi</string> - <string name="animate">Oynatma Süresi</string> - <string name="max_age_rating">Maksimum yaş sınırı</string> - <string name="no_max">Sınır yok</string> - <string name="for_all_ages">Genel İzleyici</string> - <string name="up_to_age">%s yaşa kadar</string> - <string name="screensaver">Ekran koruyucu</string> - <string name="screensaver_settings">Ekran koruyucu ayarları</string> - <string name="start_screensaver">Ekran koruyucuyu başlat</string> - <string name="duration">Süre</string> - <string name="photos">Fotoğraflar</string> - <string name="include_types">İçerik türleri</string> - <string name="include_types_summary">Görsel gösterilecek öğe türleri</string> - <string name="in_app_screensaver">Uygulama içi ekran koruyucuyu kullan</string> - <string name="actor">Aktör</string> - <string name="composer">Besteci</string> - <string name="writer">Yazar</string> - <string name="guest_star">Konuk sanatçı</string> - <string name="producer">Yapımcı</string> - <string name="conductor">Orkestra şefi</string> - <string name="lyricist">Söz yazarı</string> - <string name="arranger">Aranjör</string> - <string name="engineer">Mühendis</string> - <string name="mixer">Karıştırıcı</string> - <string name="creator">Yaratıcı</string> - <string name="artist">Sanatçı</string> - <string name="delete_item">Bu öğeyi silmek istediğinizden emin misiniz?</string> - <string name="show_media_management">Medya yönetim seçeneklerini göster</string> - <string name="search_for">Ara %s</string> - <string name="select_all">Hepsinin seç</string> - <string name="albums">Albümler</string> - <string name="artists">Sanatçılar</string> - <string name="songs">Şarkılar</string> - <string name="go_to_artist">Sanatçıya git</string> - <string name="now_playing">Şimdi oynatılıyor</string> - <string name="instant_mix">Anlık mix</string> - <string name="go_to_album">Albüme git</string> - <string name="add_to_queue">Kuyruğa ekle</string> - <string name="album_artist">Albüm sanatçısı</string> - <string name="album">Albüm</string> - <string name="popular_songs">Popüler şarkılar</string> - <string name="play_next">Sonrakini oynat</string> - <string name="music_videos">Müzik Videoları</string> - <string name="lyrics">Şarkı sözü</string> - <string name="hide_lyrics">Şarkı sözünü gizle</string> - <string name="show_lyrics">Şarkı sözünü göster</string> - <string name="song_has_lyrics">Şarkının sözleri var mı</string> - <string name="remove_from_queue">Kuyruktan sil</string> - <string name="show_album_cover">Albüm kapağını göster</string> - <string name="show_visualizer">Görselleştiriciyi göster</string> - <string name="show_backdrop">Arka Planı göster</string> - <string name="play_as_type">Nasıl çalınsın?</string> - <string name="visualizer_rationale">Çalan sesi görselleştirmek için ses kaydetme izni gerekiyor.</string> - <string name="continue_string">Devam et</string> - <string name="separate_types">Farklı türler</string> - <string name="prefer_logos">Başlık yerine logo göster</string> - <string name="updated_toast">Wolfskin %s sürümüne güncellendi</string> - <string name="view_more">Daha fazla göster</string> - <string name="discover_tv">Dizileri Keşfet</string> - <string name="discover_movies">Filmleri Keşfet</string> - <string name="remove_continue_watching">İzlemeye devam et listesinden kaldır</string> - <string name="remove_next_up">Sıradaki bölümlerden kaldır</string> - <string name="view_removed_next_up">Sıradaki bölümlerden kaldırılan dizileri göster</string> - <string name="studios_in">Stüdyolar: %1$s</string> - <string name="last_played">Son oynatma</string> - <string name="ass_subtitle_mode_libass">libass ile doğrudan oynatma</string> - <string name="ass_subtitle_mode_exoplayer">Yerleşik ExoPlayer ile doğrudan oynatma</string> - <string name="ass_subtitle_mode_transcode">Sunucuda dönüştür ve işle</string> - <string name="ass_subtitle_playback">SSA/ASS altyazı oynatma</string> - <string name="next_up_never">Asla</string> - <string name="external_player">Harici Oynatıcı</string> - <string name="system_default">Sistem varsayılanı</string> - <string name="skip_behavior">Atlama Davranışı</string> - <string name="skip_behavior_summary">İntrolar, jenerikler vb. için</string> - <string name="show_more">%1$s daha göster</string> - <string name="cinema_mode">Sinema Modu</string> - <string name="transcoding">Kod dönüştürme</string> - <string name="play_with">Şununla Oynat</string> - <string name="pin_digits_hint">[0-9]</string> - <string name="combined_search_results">Birleştirilmiş arama sonuçları</string> - <string name="combined_search_results_on">Tüm sonuçları tek listede göster</string> - <string name="combined_search_results_off">Sonuçları kategoriye göre göster</string> - <string name="results">Sonuçlar</string> - <string name="require_login">Profil için giriş zorunlu olsun</string> - <string name="display_toggles_title">Puanlama gösterimi</string> - <string name="display_toggles_summary">Nelerin görüntüleneceğini özelleştir</string> - <string name="profile_protection">Profil koruması</string> - <string name="continue_watching_click_behavior">İzlemeye Devam Et/Sıradaki Bölümler tıklama davranışı</string> - <string name="continue_watching_click_summary_on">Tıklayınca oynat</string> - <string name="continue_watching_click_summary_off">Tıklayınca detayları göster</string> - <string name="stars">Yıldızlar</string> - <string name="tomatoes">Domatesler</string> - <string name="show_voice_search_button">Sesli arama butonunu göster</string> - <string name="visible_ui">Görünür</string> - <string name="hidden_ui">Gizli</string> - <string name="user_interface_language">Kullanıcı arayüzü dili</string> - <string name="view_options_type_grid">Izgara</string> - <string name="view_options_type_list">Liste</string> - <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> - <string name="remove_from_playlist">Oynatma listesinden kaldır</string> - <string name="login_credentials_expired">Oturum süresi doldu, lütfen tekrar giriş yapın</string> - <string name="login_not_authorized">Şu anda sunucuya erişim yetkiniz bulunmuyor</string> - <string name="prefer_dovi_over_hdr10_plus">HDR10+ yerine Dolby Vision\'ı tercih et</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır</string> - <string name="max_resolution">Dönüştürme öncesi maksimum çözünürlük</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <plurals name="episodes_count"> - <item quantity="one">%1$d Bölüm</item> - <item quantity="other">%1$d Bölüm</item> - </plurals> - <string name="appears_on">Yer Aldığı</string> - <string name="top_rated">En Yüksek Puanlı</string> - <string name="hdr_formats">HDR Formatları</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="force_enable">Zorla Etkinleştir</string> - <string name="force_disable">Zorla Devre Dışı Bırak</string> - <string name="server_version_not_supported_future">Sunucu sürümü yakında desteklenmeyecek</string> - <string name="server_version_required">Gerekli sunucu sürümü: %1$s</string> - <string name="chosen_by">Seçen: %1$s</string> - <string name="user">Kullanıcı</string> - <string name="series">Diziler</string> - <string name="load_from_server_plugin">Sunucudan varsayılanları yükle</string> - <string name="save_to_local">Yalnızca bu cihaza kaydet</string> - <string name="home_settings_source_unset">Varsayılan</string> - <string name="home_settings_source_local">Cihaza özel</string> - <string name="home_settings_source_server_profile">Sunucu kullanıcı profili</string> - <string name="home_settings_source_plugin">Sunucu varsayılanı</string> - <string name="home_settings_source_web">Kullanıcı web istemcisi</string> - <string name="managed_by_server">Sunucu tarafından yönetiliyor</string> - <string name="error">Hata</string> - <string name="convert_dovi_profile_7">Dolby Vision Profile 7\'yi 8.1\'e Dönüştür</string> - <string name="convert_dovi_profile_7_summary">Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır</string> - <string name="artist_source">Sanatçı Kaynağı</string> -</resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml deleted file mode 100644 index 0c0c9727..00000000 --- a/app/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,690 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">Про застосунок</string> - <string name="active_recordings">Активні Записи</string> - <string name="add_favorite">Улюблене</string> - <string name="add_server">Додати сервер</string> - <string name="add_user">Додати користувача</string> - <string name="audio">Аудіо</string> - <string name="birthplace">Місце народження</string> - <string name="bitrate">Бітрейт</string> - <string name="born">Дата народження</string> - <string name="cancel_recording">Скасувати запис</string> - <string name="cancel_series_recording">Скасувати запис серіалу</string> - <string name="cancel">Скасувати</string> - <string name="chapters">Розділи</string> - <string name="choose_stream">Виберіть%1$s</string> - <string name="clear_image_cache">Очистити кеш зображень</string> - <string name="collection">Колекція</string> - <string name="collections">Колекції</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="critic_rating">Оцінка критиків</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="disabled">Вимкнутий</string> - <string name="discovered_servers">Виявлені сервери</string> - <string name="download_and_update"><![CDATA[Завантажити і оновити]]></string> - <string name="downloading">Завантаження…</string> - <string name="enabled">Увімкнено</string> - <string name="ends_at">Закінчується о %1$s</string> - <string name="enter_server_url">Введіть IP-адресу сервера або URL-адресу</string> - <string name="enter_server_address">Введіть адресу сервера</string> - <string name="episodes">Епізоди</string> - <string name="error_loading_collection">Помилка під час завантаження колекції %1$s</string> - <string name="external_track">Зовнішній</string> - <string name="favorites">Улюблені</string> - <string name="file_size">Розмір</string> - <string name="forced_track">Примусові</string> - <string name="genres">Жанри</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> - <string name="home">Головна</string> - <string name="immediate">Негайно</string> - <string name="intro">Вступ</string> - <string name="jump_letters">#абвгґдеєжзиіїйклмнопрстуфхцчшщьюя</string> - <string name="library">Бібліотека</string> - <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_unwatched">Позначити як непереглянуте</string> - <string name="mark_watched">Позначити як переглянуте</string> - <string name="max_bitrate">Максимальна швидкість передачі даних</string> - <string name="more_like_this">Більше подібного</string> - <string name="more">Більше</string> - <plurals name="movies"> - <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_servers_found">Сервери не знайдені</string> - <string name="no_scheduled_recordings">Заплановані записи відсутні</string> - <string name="no_update_available">Немає доступних оновлень</string> - <string name="none">Нічого</string> - <string name="only_forced_subtitles">Тільки примусові субтитри</string> - <string name="outro">Завершення</string> - <string name="path">Шлях</string> - <plurals name="people"> - <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> - <string name="play">Відтворити</string> - <string name="playback_debug_info">Показати інформацію про налагодження відтворення</string> - <string name="playback_speed">Швидкість відтворення</string> - <string name="playback">Відтворення</string> - <string name="playlist">Список відтворення</string> - <string name="playlists">Списки відтворення</string> - <string name="preview">Попередній перегляд</string> - <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_recorded">Нещодавно записано</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="restart">Перезавантаження</string> - <string name="resume">Відновити</string> - <string name="save">Зберегти</string> - <string name="search_and_download"><![CDATA[Знайти і завантажити]]></string> - <string name="search">Пошук</string> - <string name="searching">Шукаю…</string> - <string name="voice_search">Голосовий пошук</string> - <string name="voice_starting">Початок</string> - <string name="voice_search_prompt">Говорити для пошуку</string> - <string name="processing">Обробка</string> - <string name="press_back_to_cancel">Натисніть назад, щоб скасувати</string> - <string name="voice_error_audio">Помилка аудіозапису</string> - <string name="voice_error_client">Помилка розпізнавання голосу</string> - <string name="voice_error_permissions">Потрібен дозвіл на використання мікрофона</string> - <string name="voice_error_network">Помилка мережі</string> - <string name="voice_error_network_timeout">Час очікування мережі завершився</string> - <string name="voice_error_no_match">Не розпізнано мовлення</string> - <string name="voice_error_busy">Розпізнавання голосу зайнято</string> - <string name="voice_error_server">Помилка сервера</string> - <string name="voice_error_speech_timeout">Не виявлено мовлення</string> - <string name="voice_error_unknown">Невідома помилка</string> - <string name="voice_error_start_failed">Не вдалося запустити розпізнавання голосу</string> - <string name="voice_error_timeout">Час очікування розпізнавання голосу закінчився</string> - <string name="retry">Повторити спробу</string> - <string name="select_server">Обрати сервер</string> - <string name="select_user">Обрати користувача</string> - <string name="show_debug_info">Показати додаткову інформацію</string> - <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="sort_by_date_played">Дата відтворення</string> - <string name="sort_by_date_released">Дата випуску</string> - <string name="sort_by_name">Ім\'я</string> - <string name="sort_by_random">Випадковий</string> - <string name="studios">Судії</string> - <string name="submit">Надіслати</string> - <string name="subtitle_download_too_long">Завантаження триває надто довго, можливо, потрібно перезапустити відтворення</string> - <string name="subtitle">Підзаголовок</string> - <string name="subtitles">Субтитри</string> - <string name="suggestions">Пропозиції</string> - <string name="switch_servers">Перемикнути сервер</string> - <string name="switch_user">Перемкнути</string> - <string name="top_unwatched">Топ непереглянутих</string> - <plurals name="trailers"> - <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> - <item quantity="many">ТВ-шоу</item> - <item quantity="other">ТВ-шоу</item> - </plurals> - <string name="ui_interface">Інтерфейс</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="watch_live">Дивитись онлайн</string> - <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_summary_on">Натисніть центр D-Pad, щоб призупинити/відтворити</string> - <string name="italic_font">Курсив</string> - <string name="font">Шрифт</string> - <string name="background">Обкладенка</string> - <string name="success">Успіх</string> - <string name="official_rating">Вікова оцінка</string> - <string name="runtime_sort">Час виконання</string> - <string name="extras_title">Додатково</string> - <plurals name="other_extras"> - <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> - <item quantity="many">Позакадром</item> - <item quantity="other">Позакадром</item> - </plurals> - <plurals name="theme_songs"> - <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> - <item quantity="many">Тематичних відео</item> - <item quantity="other">Тематичних відео</item> - </plurals> - <plurals name="clips"> - <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> - <item quantity="many">Видалених сцен</item> - <item quantity="other">Видалених сцен</item> - </plurals> - <plurals name="interviews"> - <item quantity="one">Інтерв\'ю</item> - <item quantity="few">Інтерв\'ю</item> - <item quantity="many">Інтерв\'ю</item> - <item quantity="other">Інтерв\'ю</item> - </plurals> - <plurals name="scenes"> - <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> - <item quantity="many">Зразків</item> - <item quantity="other">Зразків</item> - </plurals> - <plurals name="featurettes"> - <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> - <item quantity="many">Коротких відео</item> - <item quantity="other">Коротких відео</item> - </plurals> - <plurals name="downloads"> - <item quantity="one">%s завантаження</item> - <item quantity="few">%s завантаження</item> - <item quantity="many">%s завантажень</item> - <item quantity="other">%s завантажень</item> - </plurals> - <plurals name="hours"> - <item quantity="one">%d година</item> - <item quantity="few">%d години</item> - <item quantity="many">%d годин</item> - <item quantity="other">%d годин</item> - </plurals> - <plurals name="items"> - <item quantity="one">%d елемент</item> - <item quantity="few">%d елемента</item> - <item quantity="many">%d елементів</item> - <item quantity="other">%d елементів</item> - </plurals> - <plurals name="seconds"> - <item quantity="one">%d секунда</item> - <item quantity="few">%d секунди</item> - <item quantity="many">%d секунд</item> - <item quantity="other">%d секунд</item> - </plurals> - <string name="ac3_supported">Пристрій підтримує AC3/Dolby Digital</string> - <string name="advanced_settings">Додаткові налаштування</string> - <string name="advanced_ui">Розширений інтерфейс користувача</string> - <string name="app_theme">Тема застосунку</string> - <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="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="global_content_scale">Масштабувати контент за замовчуванням</string> - <string name="install_update">Встановити оновлення</string> - <string name="installed_version">Встановлена версія</string> - <string name="max_homepage_items">Максимальна кількість елементів в рядку на головній сторінці</string> - <string name="nav_drawer_pins_summary">Виберіть елементи, які будуть відображатися за замовчуванням, інші будуть приховані</string> - <string name="nav_drawer_pins">Налаштування елементів навігаційного меню</string> - <string name="nav_drawer_switch_on_focus_summary_off">Натисніть, щоб перейти на іншу сторінку</string> - <string name="nav_drawer_switch_on_focus">Перемикання сторінок навігаційного меню при фокусуванні</string> - <string name="pass_out_protection">Захист від відключення</string> - <string name="play_theme_music">Відтворити музичну тему</string> - <string name="playback_overrides">Перегляд записів</string> - <string name="remember_selected_tab">Запам\'ятати вибрані вкладки</string> - <string name="rewatch_next_up">Увімкнути повторний перегляд як наступне</string> - <string name="seek_bar_steps">Кроки пошуку</string> - <string name="send_app_logs_summary">Корисно для налагодження</string> - <string name="send_app_logs">Надіслати журнали програми на поточний сервер</string> - <string name="send_crash_reports_summary">Спробує надіслати на останній підключений сервер</string> - <string name="send_crash_reports">Надіслати звіти про збій</string> - <string name="settings">Налаштування</string> - <string name="skip_back_on_resume_preference">Перейти назад при відновленні відтворення</string> - <string name="skip_back_preference">Перейти назад</string> - <string name="skip_commercials_behavior">Пропускати рекламу</string> - <string name="skip_forward_preference">Перемотати вперед</string> - <string name="skip_intro_behavior">Пропустити початок</string> - <string name="skip_outro_behavior">Пропустити кінець</string> - <string name="skip_previews_behavior">Пропусти попередній перегляд</string> - <string name="skip_recap_behavior">Пропустити підсумок</string> - <string name="update_available">Оновлення доступні</string> - <string name="update_url_summary">URL для перевірки оновлень</string> - <string name="update_url">Оновити URL</string> - <string name="font_size">Розмір шрифта</string> - <string name="font_color">Колір шрифта</string> - <string name="font_opacity">Непрозорість шрифту</string> - <string name="edge_style">Стиль контурів</string> - <string name="edge_color">Колір контуру</string> - <string name="background_opacity">Прозорість фону</string> - <string name="background_style">Стиль фону</string> - <string name="subtitle_style">Стиль заголовку</string> - <string name="background_color">Колір фону</string> - <string name="reset">Скинути</string> - <string name="bold_font">Жирний шрифт</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> - <string name="exoplayer_options">Параметри ExoPlayer</string> - <string name="skip_segment_unknown">Пропустити сегмент</string> - <string name="skip_segment_commercial">Пропустити реклами</string> - <string name="skip_segment_preview">Пропустити попередній перегляд</string> - <string name="skip_segment_recap">Пропустити повтор</string> - <string name="skip_segment_outro">Пропустити кінець</string> - <string name="skip_segment_intro">Пропустити вступ</string> - <string name="played">Відтворено</string> - <string name="filter">Фільтр</string> - <string name="year">Рік</string> - <string name="decade">Десятиліття</string> - <string name="remove">Видалити</string> - <string name="dolby_vision">Dolby Vision</string> - <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Використовувати gpu</string> - <string name="mpv_conf">Редагувати mpv.conf</string> - <string name="discard_change">Відмінити зміни?</string> - <string name="subtitle_margin">Відступ</string> - <string name="verbose_logging">Детальне ведення журналу</string> - <string name="enter_pin">Ведіть PIN</string> - <string name="sign_in_auto">Автоматичний вхід</string> - <string name="use_server_credentials">Увійти через сервер</string> - <string name="press_enter_to_confirm">Натисніть по центру для підтвердження</string> - <string name="require_pin_code">Вимагати PIN для профілю</string> - <string name="confirm_pin">Підтвердити PIN</string> - <string name="incorrect">Невірно</string> - <string name="pin_too_short">PIN-код повинен складатися з 4 або більше символів</string> - <string name="will_remove_pin">Видалити PIN-код</string> - <string name="image_cache_size">Розмір кешу зображень (МБ)</string> - <string name="view_options">Переглянути параметри</string> - <string name="columns">Колонки</string> - <string name="spacing">Відступ(Інтервал)</string> - <string name="aspect_ratio">Співвідношення сторін</string> - <string name="show_details">Показати деталі</string> - <string name="combine_continue_next"><![CDATA[Об’єднати функції «Продовжити перегляд» та «Наступне»]]></string> - <string name="image_type">Тип зображення</string> - <string name="cast_and_crew"><![CDATA[Актори та знімальна група]]></string> - <string name="guest_stars">Запрошені зірки</string> - <string name="edge_size">Розмір контуру</string> - <string name="refresh_rate_switching">Перемикання частоти оновлення</string> - <string name="automatic">Автоматично</string> - <string name="username_or_password">Використовуйте ім\'я користувача/пароль</string> - <string name="login">Увійти</string> - <string name="general">Загальні</string> - <string name="container">Контейнер</string> - <string name="title">Заголовок</string> - <string name="codec">Codec</string> - <string name="profile">Профіль</string> - <string name="level">Рівень</string> - <string name="resolution">Розширення</string> - <string name="anamorphic">Anamorphic</string> - <string name="interlaced">Interlaced</string> - <string name="framerate">Частота кадрів</string> - <string name="bit_depth">Bit depth</string> - <string name="video_range">Діапазон відео</string> - <string name="video_range_type">Тип діапазону відео</string> - <string name="color_space">Кольоровий простір</string> - <string name="color_transfer">Перенесення кольору</string> - <string name="color_primaries">Головний колір</string> - <string name="pixel_format">Формат пікселю</string> - <string name="ref_frames">Ref frames</string> - <string name="nal">NAL</string> - <string name="language">Мова</string> - <string name="layout">Компонування</string> - <string name="channels">Канали</string> - <string name="sample_rate">Sample rate</string> - <string name="avc">AVC</string> - <string name="yes">Так</string> - <string name="no">Ні</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">біт</string> - <string name="sample_rate_unit">Гц</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="color_code_programs">Кольоровий код програм</string> - <string name="subtitle_delay">Затримка субтитрів</string> - <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="clear_track_choices">Чіткий вибір доріжки</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="force_dovi_profile_7_summary">Ігноруваии перевірку сумісності пристроїв</string> - <string name="discover">Відкрийте для себе</string> - <string name="request">Запит</string> - <string name="pending">В очікувані</string> - <string name="seerr_integration">Seerr інтеграція</string> - <string name="remove_seerr_server">Видалити Seerr Server</string> - <string name="seerr_server_added">Seerr сервер додано</string> - <string name="password">Пароль</string> - <string name="username">Нік</string> - <string name="url">URL</string> - <string name="trending">Популярні</string> - <string name="upcoming_movies">Заплановані Фільми</string> - <string name="upcoming_tv">Заплановані Серіали</string> - <string name="request_4k">Запит в 4К</string> - <string name="software_decoding_av1">Програмне декодування AV1</string> - <string name="upgrade_mpv_toast">MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях.</string> - <string name="hdr_subtitle_style">HDR субтитри</string> - <string name="image_subtitle_opacity">Прозорість зображення заголовку</string> - <string name="brightness">Яскравість</string> - <string name="contrast">Контрастність</string> - <string name="saturation">Насичення</string> - <string name="hue">Відтінок</string> - <string name="red">Червоний</string> - <string name="green">Зелений</string> - <string name="blue">Синій</string> - <string name="blur">Розмиття</string> - <string name="save_for_album">Зберегти до альбому</string> - <string name="play_slideshow">Відтворити слайд-шоу</string> - <string name="stop_slideshow">Зупинити слайд-шоу</string> - <string name="slideshow_at_beginning">На початок</string> - <string name="no_more_images">Фото більше немає</string> - <string name="rotate_left">Повернути ліворуч</string> - <string name="rotate_right">Повернути праворуч</string> - <string name="zoom_in">Збільшити</string> - <string name="zoom_out">Зменшити</string> - <string name="slideshow_duration">Тривалість слайд-шоу</string> - <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> - <string name="favorite_items_title">Улюблені %s</string> - <plurals name="days"> - <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> - <string name="quick_connect_code">Введіть код швидкого підключення</string> - <string name="quick_connect_code_error">Код повинен складатися з 6 цифр</string> - <string name="quick_connect_success">Пристрій успішно авторизовано</string> - <string name="send_media_info_log_to_server">Надіслати інформацію про медіа на сервер</string> - <string name="no_limit">Без обмежень</string> - <string name="max_days_next_up">Максимальна кількість днів в Очікуються</string> - <string name="add_row">Додати рядок</string> - <string name="genres_in">Жанри %1$s</string> - <string name="recently_released_in">Нещодавно додані %1$s</string> - <string name="height">Висота</string> - <string name="apply_all_rows">Застосувати для всіх рядків</string> - <string name="customize_home">Змінити головну сторінку</string> - <string name="home_rows">Рядки на головній сторінці</string> - <string name="load_from_server">Завантажувати профіль користувача з сереверу</string> - <string name="save_to_server">Зберегти профіль користувача на сервер</string> - <string name="load_from_web_client">Завантажити з веб клієнта</string> - <string name="use_series">Використовувати зображення серіалу</string> - <string name="add_row_for">Додати рядок для %1$s</string> - <string name="overwrite_server_settings">Перезаписати налаштування на сервері?</string> - <string name="overwrite_local_settings">Перезаписати налаштування на цьому пристрої?</string> - <string name="for_episodes">Для епізодів</string> - <string name="suggestions_for">Рекомендації %1$s</string> - <string name="increase_all_cards_size">Збільшити розмір карток</string> - <string name="decrease_all_cards_size">Зменшити розмір карток</string> - <string name="use_thumb_images">Використовувати мініатюру</string> - <string name="settings_saved">Налаштування збережені</string> - <string name="display_presets">Налаштування дисплея</string> - <string name="display_presets_description">Вбудовані налаштування для швидкого оформлення всіх рядків</string> - <string name="customize_home_summary">Обери рядки і зображення для головної сторінки</string> - <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">Wolfskin за замовчуванням</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">Wolfskin оновлено до %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">Wolfskin компактний</string> -</resources> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml deleted file mode 100644 index 045e125f..00000000 --- a/app/src/main/res/values-vi/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> -</resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 07b57d8e..00000000 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,700 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="about">关于</string> - <string name="active_recordings">正在录制</string> - <string name="add_favorite">收藏</string> - <string name="add_server">添加服务器</string> - <string name="add_user">添加用户</string> - <string name="audio">音频</string> - <string name="birthplace">出生地</string> - <string name="bitrate">比特率</string> - <string name="born">出生日期</string> - <string name="cancel_recording">取消录制</string> - <string name="cancel_series_recording">取消剧集录制</string> - <string name="cancel">取消</string> - <string name="chapters">章节</string> - <string name="choose_stream">选择 %1$s</string> - <string name="clear_image_cache">清除图片缓存</string> - <string name="collection">合集</string> - <string name="collections">合集</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="critic_rating">影评人评分</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="disabled">已禁用</string> - <string name="discovered_servers">已发现的服务器</string> - <string name="download_and_update"><![CDATA[下载并更新]]></string> - <string name="downloading">正在下载…</string> - <string name="enabled">已启用</string> - <string name="enter_server_url">输入服务器 IP 或 URL</string> - <string name="episodes">剧集</string> - <string name="error_loading_collection">加载合集 %1$s 时出错</string> - <string name="external_track">外部</string> - <string name="favorites">收藏</string> - <string name="file_size">大小</string> - <string name="forced_track">强制</string> - <string name="genres">类型</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> - <string name="home">首页</string> - <string name="immediate">立即</string> - <string name="intro">片头</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">媒体库</string> - <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_unwatched">标记为未观看</string> - <string name="mark_watched">标记为已观看</string> - <string name="max_bitrate">最大比特率</string> - <string name="more_like_this">类似推荐</string> - <string name="more">更多</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_scheduled_recordings">无计划录制</string> - <string name="no_update_available">无可用更新</string> - <string name="none">无</string> - <string name="outro">片尾</string> - <string name="path">路径</string> - <string name="people_title">演职人员</string> - <string name="play_count">播放次数</string> - <string name="play_from_here">从此处播放</string> - <string name="play">播放</string> - <string name="playback_debug_info">显示播放调试信息</string> - <string name="playback_speed">播放速度</string> - <string name="playback">播放</string> - <string name="playlist">播放列表</string> - <string name="playlists">播放列表</string> - <string name="preview">预览</string> - <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_recorded">最近录制</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="restart">从头播放</string> - <string name="resume">继续播放</string> - <string name="save">保存</string> - <string name="search_and_download"><![CDATA[搜索并下载]]></string> - <string name="search">搜索</string> - <string name="searching">正在搜索…</string> - <string name="select_server">选择服务器</string> - <string name="select_user">选择用户</string> - <string name="show_debug_info">显示调试信息</string> - <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="sort_by_date_played">播放日期</string> - <string name="sort_by_date_released">发行日期</string> - <string name="sort_by_name">名称</string> - <string name="sort_by_random">随机</string> - <string name="studios">制片公司</string> - <string name="submit">提交</string> - <string name="subtitle_download_too_long">下载时间过长,可能需要重新开始播放</string> - <string name="subtitle">字幕</string> - <string name="subtitles">字幕</string> - <string name="suggestions">推荐</string> - <string name="switch_servers">切换服务器</string> - <string name="switch_user">切换</string> - <string name="top_unwatched">高分未看</string> - <plurals name="trailers"> - <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> - <string name="tv_shows_title">电视节目</string> - <string name="ui_interface">界面</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="watch_live">观看直播</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> - <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> - <string name="font">字体</string> - <string name="background">背景</string> - <string name="success">成功</string> - <string name="official_rating">内容分级</string> - <string name="runtime_sort">播放时长</string> - <string name="extras_title">附加内容</string> - <plurals name="other_extras"> - <item quantity="other">其他</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="other">幕后花絮</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="other">主题曲</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="other">主题视频</item> - </plurals> - <plurals name="clips"> - <item quantity="other">片段</item> - </plurals> - <plurals name="deleted_scenes"> - <item quantity="other">删减片段</item> - </plurals> - <plurals name="interviews"> - <item quantity="other">访谈</item> - </plurals> - <plurals name="downloads"> - <item quantity="other">%s 次下载</item> - </plurals> - <plurals name="hours"> - <item quantity="other">%d 小时</item> - </plurals> - <plurals name="items"> - <item quantity="other">%d 个项目</item> - </plurals> - <plurals name="seconds"> - <item quantity="other">%d 秒</item> - </plurals> - <plurals name="scenes"> - <item quantity="other">场景</item> - </plurals> - <plurals name="samples"> - <item quantity="other">样片</item> - </plurals> - <plurals name="featurettes"> - <item quantity="other">特辑</item> - </plurals> - <plurals name="shorts"> - <item quantity="other">短片</item> - </plurals> - <string name="ac3_supported">设备支持 AC3/杜比数字</string> - <string name="advanced_settings">高级设置</string> - <string name="advanced_ui">高级用户界面</string> - <string name="app_theme">应用程序主题</string> - <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="combine_continue_next"><![CDATA[合并继续观看与即将播放]]></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="global_content_scale">默认内容比例</string> - <string name="install_update">安装更新</string> - <string name="installed_version">安装版本</string> - <string name="max_homepage_items">首页行最大项目数</string> - <string name="nav_drawer_pins_summary">选择默认显示的项目,其他项目将隐藏</string> - <string name="nav_drawer_pins">自定义抽屉式导航栏项目</string> - <string name="nav_drawer_switch_on_focus_summary_off">点击切换页面</string> - <string name="nav_drawer_switch_on_focus">获得焦点时切换抽屉式导航栏页面</string> - <string name="pass_out_protection">防休眠保护</string> - <string name="play_theme_music">播放主题音乐</string> - <string name="playback_overrides">播放覆盖设置</string> - <string name="remember_selected_tab">记住所选标签页</string> - <string name="rewatch_next_up">在即将播放中重复观看</string> - <string name="seek_bar_steps">进度条步进值</string> - <string name="send_app_logs_summary">用于调试</string> - <string name="send_app_logs">将应用日志发送到当前服务器</string> - <string name="send_crash_reports_summary">将尝试发送到最后连接的服务器</string> - <string name="send_crash_reports">发送崩溃报告</string> - <string name="settings">设置</string> - <string name="skip_back_on_resume_preference">恢复播放时自动向后跳过</string> - <string name="skip_back_preference">向后跳过</string> - <string name="skip_commercials_behavior">跳过广告方式</string> - <string name="skip_forward_preference">向前跳过</string> - <string name="skip_intro_behavior">跳过片头方式</string> - <string name="skip_outro_behavior">跳过片尾方式</string> - <string name="skip_previews_behavior">跳过预览方式</string> - <string name="skip_recap_behavior">跳过前情提要方式</string> - <string name="update_available">有可用更新</string> - <string name="update_url_summary">用于检查应用更新的 URL</string> - <string name="update_url">更新 URL</string> - <string name="font_size">字体大小</string> - <string name="font_color">字体颜色</string> - <string name="font_opacity">字体不透明度</string> - <string name="edge_style">边缘样式</string> - <string name="edge_color">边缘颜色</string> - <string name="background_opacity">背景不透明度</string> - <string name="background_style">背景样式</string> - <string name="subtitle_style">字幕样式</string> - <string name="background_color">背景颜色</string> - <string name="reset">重置</string> - <string name="bold_font">粗体</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> - <string name="exoplayer_options">ExoPlayer 选项</string> - <string name="skip_segment_unknown">跳过片段</string> - <string name="skip_segment_commercial">跳过广告</string> - <string name="skip_segment_preview">跳过预览</string> - <string name="skip_segment_recap">跳过前情提要</string> - <string name="skip_segment_outro">跳过片尾</string> - <string name="skip_segment_intro">跳过片头</string> - <string name="played">已观看</string> - <string name="filter">筛选</string> - <string name="year">年份</string> - <string name="decade">年代</string> - <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_conf">编辑 mpv.conf</string> - <string name="discard_change">舍弃更改?</string> - <string name="subtitle_margin">边距</string> - <string name="verbose_logging">详细日志记录</string> - <string name="enter_pin">输入 PIN 码</string> - <string name="sign_in_auto">自动登录</string> - <string name="use_server_credentials">通过服务器登录</string> - <string name="press_enter_to_confirm">按中心键确认</string> - <string name="require_pin_code">配置需要 PIN 码</string> - <string name="confirm_pin">确认 PIN 码</string> - <string name="incorrect">错误</string> - <string name="pin_too_short">PIN 码必须至少包含 4 个字符</string> - <string name="will_remove_pin">将移除 PIN 码</string> - <string name="image_cache_size">图片磁盘缓存大小(MB)</string> - <string name="view_options">查看选项</string> - <string name="columns">列</string> - <string name="spacing">间距</string> - <string name="aspect_ratio">宽高比</string> - <string name="show_details">显示详情</string> - <string name="image_type">图片类型</string> - <string name="cast_and_crew"><![CDATA[演员与制作人员]]></string> - <string name="guest_stars">客串演员</string> - <string name="edge_size">边缘尺寸</string> - <string name="refresh_rate_switching">刷新率切换</string> - <string name="automatic">自动</string> - <string name="username_or_password">使用用户名/密码</string> - <string name="login">登录</string> - <string name="show_titles">显示标题</string> - <string name="media_information">媒体信息</string> - <string name="general">常规</string> - <string name="container">容器格式</string> - <string name="title">标题</string> - <string name="codec">编解码器</string> - <string name="profile">配置</string> - <string name="level">等级</string> - <string name="resolution">分辨率</string> - <string name="anamorphic">变形宽银幕</string> - <string name="interlaced">隔行扫描</string> - <string name="framerate">帧速率</string> - <string name="bit_depth">位深度</string> - <string name="video_range">视频范围</string> - <string name="video_range_type">视频范围类型</string> - <string name="color_space">色彩空间</string> - <string name="color_transfer">色彩传递</string> - <string name="color_primaries">色彩原色</string> - <string name="pixel_format">像素格式</string> - <string name="ref_frames">参考帧数</string> - <string name="nal">NAL</string> - <string name="language">语言</string> - <string name="layout">布局</string> - <string name="channels">频道</string> - <string name="sample_rate">采样率</string> - <string name="avc">AVC</string> - <string name="yes">是</string> - <string name="no">否</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">位</string> - <string name="sample_rate_unit">Hz</string> - <string name="live_tv_repeat">重复</string> - <string name="favorite_channels_at_beginning">优先显示收藏频道</string> - <string name="sort_channels_recently_watched">按最近观看的频道排序</string> - <string name="color_code_programs">节目颜色标记</string> - <string name="subtitle_delay">字幕延迟</string> - <string name="enter_server_address">输入服务器地址</string> - <string name="no_servers_found">未找到服务器</string> - <string name="backdrop_display">背景幕样式</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="only_forced_subtitles">仅强制字幕</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">直通播放杜比视界配置文件 7</string> - <string name="force_dovi_profile_7_summary">忽略设备兼容性检查</string> - <string name="discover">发现</string> - <string name="request">请求</string> - <string name="pending">待处理</string> - <string name="seerr_integration">Seerr 集成</string> - <string name="remove_seerr_server">移除 Seerr 服务器</string> - <string name="password">密码</string> - <string name="username">用户名</string> - <string name="url">URL</string> - <string name="trending">热门趋势</string> - <string name="upcoming_movies">即将上映的电影</string> - <string name="upcoming_tv">即将播出的电视节目</string> - <string name="request_4k">请求 4K 版本</string> - <string name="seerr_server_added">Seerr 服务器已添加</string> - <string name="voice_search">语音搜索</string> - <string name="voice_starting">开始</string> - <string name="voice_search_prompt">说话进行搜索</string> - <string name="processing">正在处理</string> - <string name="press_back_to_cancel">按返回键取消</string> - <string name="voice_error_audio">音频录制错误</string> - <string name="voice_error_client">语音识别错误</string> - <string name="voice_error_permissions">需要麦克风权限</string> - <string name="voice_error_network">网络错误</string> - <string name="voice_error_network_timeout">网络超时</string> - <string name="voice_error_no_match">未识别到语音</string> - <string name="voice_error_busy">语音识别繁忙</string> - <string name="voice_error_server">服务器错误</string> - <string name="voice_error_speech_timeout">未检测到语音</string> - <string name="voice_error_unknown">未知错误</string> - <string name="voice_error_start_failed">无法启动语音识别</string> - <string name="voice_error_timeout">语音识别超时</string> - <string name="retry">重试</string> - <string name="software_decoding_av1">AV1 软件解码</string> - <string name="upgrade_mpv_toast">除 HDR 视频外,MPV 现在是默认播放器。\n您可以在设置中更改此设置。</string> - <string name="hdr_subtitle_style">HDR 字幕样式</string> - <string name="image_subtitle_opacity">图像字幕不透明度</string> - <string name="brightness">亮度</string> - <string name="contrast">对比度</string> - <string name="saturation">饱和度</string> - <string name="hue">色调</string> - <string name="red">红色</string> - <string name="green">绿色</string> - <string name="blue">蓝色</string> - <string name="blur">模糊</string> - <string name="save_for_album">保存到相册</string> - <string name="play_slideshow">播放幻灯片</string> - <string name="stop_slideshow">停止幻灯片</string> - <string name="slideshow_at_beginning">回到开头</string> - <string name="no_more_images">没有更多照片</string> - <string name="rotate_left">向左旋转</string> - <string name="rotate_right">向右旋转</string> - <string name="zoom_in">放大</string> - <string name="zoom_out">缩小</string> - <string name="slideshow_duration">幻灯片时长</string> - <string name="play_videos_during_slideshow">幻灯片播放期间播放视频</string> - <string name="send_media_info_log_to_server">将媒体信息日志发送到服务器</string> - <plurals name="days"> - <item quantity="other">%s 天</item> - </plurals> - <string name="no_limit">无限制</string> - <string name="max_days_next_up">即将播放中的最大天数</string> - <string name="quick_connect">快速连接</string> - <string name="quick_connect_summary">授权其他设备登录您的账号</string> - <string name="quick_connect_code">输入快速连接代码</string> - <string name="quick_connect_code_error">代码必须为六位数字</string> - <string name="quick_connect_success">设备授权成功</string> - <string name="add_row">添加行</string> - <string name="genres_in">%1$s 中的类型</string> - <string name="recently_released_in">%1$s 最近发行</string> - <string name="height">高度</string> - <string name="apply_all_rows">应用到所有行</string> - <string name="customize_home">自定义首页</string> - <string name="home_rows">首页行</string> - <string name="load_from_server">从服务器用户配置加载</string> - <string name="save_to_server">保存到服务器用户配置</string> - <string name="load_from_web_client">从网络客户端加载</string> - <string name="use_series">使用剧集图片</string> - <string name="add_row_for">为 %1$s 添加行</string> - <string name="overwrite_server_settings">覆盖服务器上的设置?</string> - <string name="overwrite_local_settings">覆盖本地设置?</string> - <string name="for_episodes">针对剧集</string> - <string name="suggestions_for">%1$s 推荐内容</string> - <string name="increase_all_cards_size">增大所有卡片的尺寸</string> - <string name="decrease_all_cards_size">减小所有卡片的尺寸</string> - <string name="use_thumb_images">使用缩略图</string> - <string name="settings_saved">设置已保存</string> - <string name="display_presets">显示预设</string> - <string name="display_presets_description">可快速设置所有行样式的内置预设</string> - <string name="customize_home_summary">选择首页上的行和图片</string> - <string name="favorite_items_title">收藏的 %s</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">Wolfskin 默认</string> - <string name="display_preset_compact">Wolfskin 紧凑</string> - <string name="display_preset_series_thumb">剧集缩略图</string> - <string name="display_preset_episode_thumbnails">分集缩略图</string> - <string name="purple">紫色</string> - <string name="orange">橙色</string> - <string name="bold_blue">深蓝色</string> - <string name="black">黑色</string> - <string name="skip_ignore">忽略</string> - <string name="skip_automatically">自动跳过</string> - <string name="skip_ask">询问后跳过</string> - <string name="ffmpeg_fallback">仅在无内置解码器时使用 FFmpeg</string> - <string name="ffmpeg_prefer">优先使用 FFmpeg 解码器而非内置解码器</string> - <string name="ffmpeg_never">从不使用 FFmpeg 解码器</string> - <string name="next_up_playback_end">播放结束时</string> - <string name="next_up_outro">播到片尾时</string> - <string name="white">白色</string> - <string name="light_gray">浅灰色</string> - <string name="dark_gray">深灰色</string> - <string name="yellow">黄色</string> - <string name="cyan">青色</string> - <string name="magenta">紫红色</string> - <string name="subtitle_edge_outline">轮廓</string> - <string name="subtitle_edge_shadow">阴影</string> - <string name="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> - <string name="aspect_ratios_square">正方形 (1:1)</string> - <string name="image_type_primary">主图</string> - <string name="image_type_thumb">缩略图</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="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="background_style_wrap">环绕式</string> - <string name="background_style_boxed">框式</string> - <string name="series_continueing">当前</string> - <plurals name="minutes"> - <item quantity="other">%s 分钟</item> - </plurals> - <string name="animate">动画</string> - <string name="start_after">启动时间</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="in_app_screensaver">使用应用内屏幕保护程序</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="show_media_management">显示媒体管理选项</string> - <string name="delete_item">是否确定要删除此项目?</string> - <string name="search_for">搜索 %s</string> - <string name="select_all">全选</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="remove_from_queue">从待播列表中移除</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="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="separate_types">分离类型</string> - <string name="prefer_logos">优先显示标题图标</string> - <string name="updated_toast">Wolfskin 已更新至 %s</string> - <string name="view_more">查看更多</string> - <string name="discover_tv">发现电视节目</string> - <string name="discover_movies">发现电影</string> - <string name="remove_continue_watching">从继续观看中移除</string> - <string name="remove_next_up">从即将播放中移除剧集</string> - <string name="view_removed_next_up">查看已从即将播放中移除的剧集</string> - <string name="studios_in">%1$s 制片公司</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="next_up_never">从不</string> - <string name="last_played">上次播放</string> - <string name="external_player">外部播放器</string> - <string name="system_default">系统默认</string> - <string name="show_more">显示更多 %1$s 条</string> - <string name="skip_behavior">跳过行为</string> - <string name="skip_behavior_summary">适用于片头、片尾等</string> - <string name="cinema_mode">影院模式</string> - <string name="play_with">播放方式</string> - <string name="transcoding">转码</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="continue_watching_click_behavior">点击继续观看/即将播放时的行为</string> - <string name="continue_watching_click_summary_on">点击时播放</string> - <string name="continue_watching_click_summary_off">点击时显示详情</string> - <string name="require_login">配置需要登录</string> - <string name="display_toggles_title">评分显示</string> - <string name="display_toggles_summary">自定义显示内容</string> - <string name="profile_protection">配置保护</string> - <string name="stars">星级评分</string> - <string name="tomatoes">番茄评分</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="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> - <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_credentials_expired">凭据已过期,请重新登录</string> - <string name="login_not_authorized">您目前无权访问该服务器</string> -</resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index c82ff0d8..00000000 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,732 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="file_size">大小</string> - <string name="hide">隱藏</string> - <string name="home">首頁</string> - <string name="immediate">立即</string> - <string name="intro">片頭</string> - <string name="library">媒體</string> - <string name="loading">載入中…</string> - <string name="more_like_this">類似作品</string> - <string name="more">更多</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_scheduled_recordings">無排程錄影</string> - <string name="no_update_available">無可用更新</string> - <string name="none">無</string> - <string name="path">路徑</string> - <string name="play_count">播放次數</string> - <string name="play">播放</string> - <string name="playback_speed">播放速率</string> - <string name="playlist">播放清單</string> - <string name="playlists">播放清單</string> - <string name="preview">預覽</string> - <string name="profile_specific_settings">個人化設定</string> - <string name="queue">待播佇列</string> - <string name="recently_added">最近新增</string> - <string name="recently_recorded">最近錄製</string> - <string name="recently_released">最近發行</string> - <string name="recommended">推薦</string> - <string name="record_program">錄製節目</string> - <string name="restart">從頭播放</string> - <string name="save">儲存</string> - <string name="search">搜尋</string> - <string name="searching">搜尋中…</string> - <string name="select_server">選擇伺服器</string> - <string name="select_user">選擇使用者</string> - <string name="show_debug_info">顯示偵錯資訊</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="sort_by_date_released">發行日期</string> - <string name="sort_by_name">名稱</string> - <string name="sort_by_random">隨機</string> - <string name="submit">提交</string> - <string name="subtitle">字幕</string> - <string name="subtitles">字幕</string> - <string name="suggestions">推薦</string> - <string name="switch_servers">切換伺服器</string> - <string name="switch_user">切換</string> - <plurals name="trailers"> - <item quantity="other">預告片</item> - </plurals> - <string name="tv_guide">指南</string> - <string name="updates">更新</string> - <string name="version">版本</string> - <string name="video_scale">畫面縮放比例</string> - <string name="video">影片</string> - <string name="videos">影片</string> - <string name="watch_live">觀看直播</string> - <string name="show_clock">顯示時間</string> - <string name="create_playlist">新增播放清單</string> - <string name="add_to_playlist">加入播放清單</string> - <string name="one_click_pause">單擊暫停</string> - <string name="italic_font">斜體</string> - <string name="font">字體</string> - <string name="background">背景</string> - <string name="success">成功</string> - <string name="official_rating">內容分級</string> - <string name="runtime_sort">片長</string> - <plurals name="other_extras"> - <item quantity="other">其他</item> - </plurals> - <plurals name="behind_the_scenes"> - <item quantity="other">幕後花絮</item> - </plurals> - <plurals name="theme_songs"> - <item quantity="other">主題曲</item> - </plurals> - <plurals name="theme_videos"> - <item quantity="other">主題影片</item> - </plurals> - <plurals name="clips"> - <item quantity="other">片段</item> - </plurals> - <plurals name="interviews"> - <item quantity="other">訪談</item> - </plurals> - <plurals name="shorts"> - <item quantity="other">短片</item> - </plurals> - <string name="ac3_supported">裝置支援 AC3/杜比數位音效</string> - <string name="advanced_settings">進階設定</string> - <string name="advanced_ui">進階UI</string> - <string name="app_theme">應用程式主題</string> - <string name="auto_check_for_updates">自動檢查更新</string> - <string name="auto_play_next">自動播放下一集</string> - <string name="check_for_updates">檢查更新</string> - <string name="direct_play_ass">使用 libass 播放 ASS 字幕</string> - <string name="direct_play_pgs">直接播放 PGS 字幕</string> - <string name="ffmpeg_extension_pref">使用 FFmpeg 解碼模組</string> - <string name="global_content_scale">預設縮放比例</string> - <string name="install_update">安裝更新</string> - <string name="installed_version">安裝版本</string> - <string name="max_homepage_items">首頁每列項目上限</string> - <string name="nav_drawer_pins_summary">選擇預設顯示項目,其餘將會隱藏</string> - <string name="nav_drawer_pins">自訂側邊選單項目</string> - <string name="nav_drawer_switch_on_focus_summary_off">點擊切換頁面</string> - <string name="play_theme_music">播放主題音樂</string> - <string name="remember_selected_tab">記住選取的分頁</string> - <string name="send_crash_reports">傳送崩潰報告</string> - <string name="settings">設定</string> - <string name="update_available">有可用更新</string> - <string name="update_url">更新的 URL</string> - <string name="font_size">字體大小</string> - <string name="font_color">字體顏色</string> - <string name="font_opacity">字體不透明度</string> - <string name="edge_style">邊框樣式</string> - <string name="edge_color">邊框顏色</string> - <string name="background_opacity">背景不透明度</string> - <string name="background_style">背景樣式</string> - <string name="subtitle_style">字幕樣式</string> - <string name="background_color">背景顏色</string> - <string name="reset">重設</string> - <string name="bold_font">粗體</string> - <string name="mpv_hardware_decoding">MPV:使用硬體解碼</string> - <string name="disable_if_crash">遇到崩潰時請停用</string> - <string name="mpv_options">MPV 選項</string> - <string name="exoplayer_options">ExoPlayer 選項</string> - <string name="skip_segment_commercial">跳過廣告</string> - <string name="skip_segment_preview">跳過預告</string> - <string name="filter">篩選</string> - <string name="year">年份</string> - <string name="remove">移除</string> - <string name="about">關於</string> - <string name="active_recordings">正在錄製</string> - <string name="add_favorite">加入我的最愛</string> - <string name="add_server">新增伺服器</string> - <string name="add_user">新增使用者</string> - <string name="audio">音訊</string> - <string name="birthplace">出生地</string> - <string name="bitrate">位元率</string> - <string name="born">生日</string> - <string name="cancel_recording">取消錄製</string> - <string name="cancel_series_recording">取消劇集錄製</string> - <string name="cancel">取消</string> - <string name="chapters">章節</string> - <string name="choose_stream">選擇 %1$s</string> - <string name="clear_image_cache">清除圖片快取</string> - <string name="collection">系列</string> - <string name="collections">系列</string> - <string name="commercial">廣告</string> - <string name="community_rating">IMDb 評分</string> - <string name="compose_error_message_title">發生錯誤!請按下按鈕將日誌傳送到您的伺服器。</string> - <string name="confirm">確認</string> - <string name="continue_watching">繼續觀看</string> - <string name="critic_rating">影評人評分</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="disabled">已停用</string> - <string name="discovered_servers">已發現的伺服器</string> - <string name="download_and_update"><![CDATA[下載並更新]]></string> - <string name="downloading">下載中…</string> - <string name="enabled">已啟用</string> - <string name="enter_server_url">輸入伺服器 IP 位址或 URL</string> - <string name="episodes">單集</string> - <string name="favorites">我的最愛</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="error_loading_collection">載入系列 %1$s 失敗</string> - <string name="genres">類型</string> - <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="license_info">授權資訊</string> - <string name="live_tv">電視直播</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="outro">片尾</string> - <string name="people_title">演職人員</string> - <string name="play_from_here">從此處播放</string> - <string name="playback_debug_info">顯示播放偵錯資訊</string> - <string name="playback">播放</string> - <string name="recap">前情提要</string> - <string name="recently_added_in">最近新增於 %1$s</string> - <string name="record_series">錄製劇集</string> - <string name="remove_favorite">移出我的最愛</string> - <string name="resume">繼續播放</string> - <string name="search_and_download"><![CDATA[搜尋並下載]]></string> - <string name="studios">製作公司</string> - <string name="top_unwatched">高評分未觀看</string> - <string name="tv_dvr_schedule">錄影排程</string> - <string name="tv_shows_title">電視劇</string> - <string name="ui_interface">介面</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"> - <item quantity="other">刪剪片段</item> - </plurals> - <plurals name="downloads"> - <item quantity="other">%s 次下載</item> - </plurals> - <plurals name="hours"> - <item quantity="other">%d 小時</item> - </plurals> - <plurals name="items"> - <item quantity="other">%d 個項目</item> - </plurals> - <plurals name="seconds"> - <item quantity="other">%d 秒</item> - </plurals> - <string name="auto_play_next_delay">播放下一集前的延遲時間</string> - <string name="combine_continue_next_summary">僅適用於電視劇</string> - <string name="combine_continue_next"><![CDATA[合併繼續觀看與接下來播放]]></string> - <string name="downmix_stereo">一律降轉為雙聲道</string> - <string name="nav_drawer_switch_on_focus">選取選單時自動切換頁面</string> - <string name="pass_out_protection">防睡保護</string> - <string name="rewatch_next_up">在接下來播放中顯示重播內容</string> - <string name="show_next_up_when">顯示接下來播放</string> - <string name="skip_segment_recap">跳過前情提要</string> - <string name="skip_segment_outro">跳過片尾</string> - <string name="skip_segment_intro">跳過片頭</string> - <string name="player_backend">播放引擎</string> - <string name="subtitle_download_too_long">下載時間過長,建議重新播放</string> - <string name="update_url_summary">用於檢查程式更新的 URL</string> - <string name="external_track">外部</string> - <string name="forced_track">強制</string> - <string name="sort_by_date_played">播放日期</string> - <string name="unknown">未知</string> - <plurals name="scenes"> - <item quantity="other">精選片段</item> - </plurals> - <string name="extras_title">特別收錄</string> - <plurals name="samples"> - <item quantity="other">試看片段</item> - </plurals> - <plurals name="featurettes"> - <item quantity="other">幕後特輯</item> - </plurals> - <string name="skip_back_preference">倒退</string> - <string name="skip_forward_preference">快轉</string> - <string name="dolby_vision">杜比視界</string> - <string name="dolby_atmos">杜比全景聲</string> - <string name="decade">年代</string> - <string name="skip_segment_unknown">跳過片段</string> - <string name="played">已觀看</string> - <string name="aired_episode_order">播出順序</string> - <string name="tv_season">季</string> - <string name="tv_seasons">季數</string> - <string name="skip_back_on_resume_preference">繼續播放時自動倒退</string> - <string name="skip_commercials_behavior">跳過廣告方式</string> - <string name="skip_intro_behavior">跳過片頭方式</string> - <string name="skip_outro_behavior">跳過片尾方式</string> - <string name="skip_previews_behavior">跳過預告方式</string> - <string name="skip_recap_behavior">跳過前情提要方式</string> - <string name="send_crash_reports_summary">將嘗試傳送至最近連線的伺服器</string> - <string name="send_app_logs">傳送程式日誌到目前伺服器</string> - <string name="send_app_logs_summary">有助於偵錯</string> - <string name="subtitle_margin">邊距</string> - <string name="verbose_logging">詳細日誌記錄</string> - <string name="require_pin_code">使用 PIN 鍵登入</string> - <string name="media_information">媒體資訊</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="enter_pin">輸入 PIN 碼</string> - <string name="sign_in_auto">自動登入</string> - <string name="use_server_credentials">透過伺服器登入</string> - <string name="press_enter_to_confirm">按下中間鍵確認</string> - <string name="confirm_pin">確認 PIN 碼</string> - <string name="incorrect">錯誤</string> - <string name="pin_too_short">PIN 碼至少需 4 碼</string> - <string name="will_remove_pin">將移除 PIN 碼</string> - <string name="image_cache_size">圖片快取大小 (MB)</string> - <string name="view_options">檢視選項</string> - <string name="columns">欄數</string> - <string name="spacing">間距</string> - <string name="aspect_ratio">長寬比</string> - <string name="show_details">詳細資訊</string> - <string name="image_type">圖片類型</string> - <string name="cast_and_crew"><![CDATA[演職人員]]></string> - <string name="guest_stars">客串演員</string> - <string name="edge_size">邊框大小</string> - <string name="refresh_rate_switching">畫面更新率切換</string> - <string name="automatic">自動</string> - <string name="username_or_password">使用帳號/密碼</string> - <string name="login">登入</string> - <string name="subtitle_delay">字幕同步</string> - <string name="show_titles">顯示標題</string> - <string name="enter_server_address">輸入伺服器位址</string> - <string name="no_servers_found">未找到伺服器</string> - <string name="backdrop_display">背景圖樣式</string> - <string name="color_code_programs">節目顏色標記</string> - <string name="favorite_channels_at_beginning">優先顯示最愛頻道</string> - <string name="sort_channels_recently_watched">按最近觀看的頻道排序</string> - <string name="ends_at">結束於 %1$s</string> - <string name="general">一般</string> - <string name="container">封裝格式</string> - <string name="title">標題</string> - <string name="codec">編碼</string> - <string name="resolution">解析度</string> - <string name="anamorphic">變形寬銀幕</string> - <string name="level">等級</string> - <string name="profile">編碼設定檔</string> - <string name="language">語言</string> - <string name="yes">是</string> - <string name="no">否</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="live_tv_repeat">重複</string> - <string name="interlaced">隔行掃描</string> - <string name="ref_frames">參考影格數</string> - <string name="framerate">影格率</string> - <string name="nal">NAL</string> - <string name="bit_depth">位元深度</string> - <string name="avc">AVC</string> - <string name="layout">配置</string> - <string name="channels">聲道數</string> - <string name="video_range">動態範圍</string> - <string name="video_range_type">動態範圍類型</string> - <string name="color_space">色彩空間</string> - <string name="color_transfer">色彩轉換</string> - <string name="color_primaries">色彩原色</string> - <string name="pixel_format">像素格式</string> - <string name="sample_rate">取樣率</string> - <string name="seek_bar_steps">進度條跳轉值</string> - <string name="playback_overrides">播放覆蓋設定</string> - <string name="resolution_switching">解析度切換</string> - <string name="play_trailer">播放預告片</string> - <string name="local">本地</string> - <string name="no_trailers">無預告片</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">直接播放杜比視界 Profile 7</string> - <string name="force_dovi_profile_7_summary">忽略設備相容性檢查</string> - <string name="discover">探索</string> - <string name="request">請求</string> - <string name="pending">待處理</string> - <string name="seerr_integration">Seerr 整合</string> - <string name="remove_seerr_server">移除 Seerr 伺服器</string> - <string name="seerr_server_added">已新增 Seerr 伺服器</string> - <string name="password">密碼</string> - <string name="username">帳號</string> - <string name="url">URL</string> - <string name="trending">當前趨勢</string> - <string name="upcoming_movies">即將上映的電影</string> - <string name="upcoming_tv">即將播出的電視劇</string> - <string name="request_4k">請求 4K 版本</string> - <string name="only_forced_subtitles">僅強制字幕</string> - <string name="clear_track_choices">清除音軌/字幕選擇</string> - <string name="voice_search">語音搜尋</string> - <string name="voice_starting">開始</string> - <string name="voice_search_prompt">請說出搜尋內容</string> - <string name="processing">處理中</string> - <string name="press_back_to_cancel">按返回鍵取消</string> - <string name="voice_error_audio">語音錄製錯誤</string> - <string name="voice_error_client">語音辨識錯誤</string> - <string name="voice_error_permissions">請開啟麥克風權限</string> - <string name="voice_error_network">網路錯誤</string> - <string name="voice_error_network_timeout">網路逾時</string> - <string name="voice_error_no_match">未辨識到語音</string> - <string name="voice_error_busy">語音辨識忙碌中</string> - <string name="voice_error_server">伺服器錯誤</string> - <string name="voice_error_speech_timeout">未偵測到語音</string> - <string name="voice_error_unknown">未知錯誤</string> - <string name="voice_error_start_failed">無法啟動語音辨識</string> - <string name="voice_error_timeout">語音辨識逾時</string> - <string name="retry">重試</string> - <string name="software_decoding_av1">AV1 軟體解碼</string> - <string name="upgrade_mpv_toast">除了 HDR 影片外,MPV 已成為預設播放器。 \n您可以在設定中變更此設定。</string> - <string name="hdr_subtitle_style">HDR 字幕樣式</string> - <string name="contrast">對比</string> - <string name="saturation">飽和度</string> - <string name="hue">色調</string> - <string name="red">紅色</string> - <string name="green">綠色</string> - <string name="blue">藍色</string> - <string name="blur">模糊</string> - <string name="save_for_album">存為相簿</string> - <string name="play_slideshow">播放幻燈片</string> - <string name="stop_slideshow">停播幻燈片</string> - <string name="slideshow_at_beginning">從頭開始</string> - <string name="no_more_images">已無更多照片</string> - <string name="rotate_left">向左旋轉</string> - <string name="rotate_right">向右旋轉</string> - <string name="zoom_in">放大</string> - <string name="zoom_out">縮小</string> - <string name="slideshow_duration">幻燈片播放時間</string> - <string name="play_videos_during_slideshow">幻燈片播放時播放影片</string> - <string name="image_subtitle_opacity">圖形字幕不透明度</string> - <string name="brightness">亮度</string> - <plurals name="days"> - <item quantity="other">%s 天</item> - </plurals> - <string name="send_media_info_log_to_server">將媒體訊息日誌傳送到伺服器</string> - <string name="no_limit">無限制</string> - <string name="max_days_next_up">接下來播放的保留天數</string> - <string name="quick_connect">快速連線</string> - <string name="quick_connect_summary">授權其他裝置登入您的帳號</string> - <string name="quick_connect_code">輸入快速連線驗證碼</string> - <string name="quick_connect_code_error">驗證碼必須為6位數</string> - <string name="quick_connect_success">裝置授權成功</string> - <string name="genres_in">%1$s 類型</string> - <string name="recently_released_in">最近發行於 %1$s</string> - <string name="suggestions_for">推薦的 %1$s</string> - <string name="increase_all_cards_size">放大所有海報尺寸</string> - <string name="decrease_all_cards_size">縮小所有海報尺寸</string> - <string name="use_thumb_images">使用橫式縮圖</string> - <string name="add_row">新增一列</string> - <string name="apply_all_rows">套用到所有列</string> - <string name="home_rows">首頁各列</string> - <string name="add_row_for">為 %1$s 新增列項目</string> - <string name="display_presets_description">快速設定所有項目為內建預設樣式</string> - <string name="customize_home_summary">選擇首頁要顯示的項目及圖片樣式</string> - <string name="height">高度</string> - <string name="customize_home">自訂首頁</string> - <string name="load_from_server">從伺服器載入設定</string> - <string name="save_to_server">保存設定到伺服器</string> - <string name="load_from_web_client">從網頁版載入設定</string> - <string name="use_series">使用劇集圖片</string> - <string name="overwrite_server_settings">覆蓋伺服器上的設定?</string> - <string name="overwrite_local_settings">覆蓋本地設定?</string> - <string name="settings_saved">設定已儲存</string> - <string name="display_presets">顯示預設樣式</string> - <string name="for_episodes">針對單集</string> - <string name="content_scale_fit">原比例縮放</string> - <string name="content_scale_crop">裁切填滿</string> - <string name="content_scale_fill">拉伸填滿</string> - <string name="content_scale_fill_width">填滿寬度</string> - <string name="content_scale_fill_height">填滿高度</string> - <string name="volume_lowest">最小音量</string> - <string name="volume_low">小音量</string> - <string name="volume_medium">中等音量</string> - <string name="volume_high">大音量</string> - <string name="volume_full">最大音量</string> - <string name="purple">紫色</string> - <string name="orange">橘色</string> - <string name="bold_blue">深藍色</string> - <string name="black">黑色</string> - <string name="skip_ignore">不處理</string> - <string name="skip_automatically">自動跳過</string> - <string name="skip_ask">詢問是否跳過</string> - <string name="ffmpeg_fallback">僅在無內建解碼器時使用 FFmpeg</string> - <string name="ffmpeg_prefer">優先使用 FFmpeg 而非內建解碼器</string> - <string name="ffmpeg_never">不使用 FFmpeg 解碼器</string> - <string name="next_up_playback_end">在播放結束時</string> - <string name="next_up_outro">在播到片尾時</string> - <string name="white">白色</string> - <string name="light_gray">淺灰色</string> - <string name="dark_gray">深灰色</string> - <string name="yellow">黃色</string> - <string name="cyan">青色</string> - <string name="magenta">桃紅色</string> - <string name="subtitle_edge_outline">外框</string> - <string name="subtitle_edge_shadow">陰影</string> - <string name="background_style_wrap">貼合文字</string> - <string name="background_style_boxed">長方底框</string> - <string name="prefer_mpv">優先使用 MPV</string> - <string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string> - <string name="favorite_items_title">我的最愛的 %s</string> - <string name="display_preset_default">Wolfskin 預設樣式</string> - <string name="display_preset_compact">Wolfskin 緊湊樣式</string> - <string name="display_preset_series_thumb">劇集縮圖樣式</string> - <string name="display_preset_episode_thumbnails">單集縮圖樣式</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="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">Seerr 帳號</string> - <string name="search_and_download_subtitles"><![CDATA[搜尋和下載字幕]]></string> - <string name="no_subtitles_found">未找到遠端字幕</string> - <string name="series_continueing">至今</string> - <plurals name="minutes"> - <item quantity="other">%s 分鐘</item> - </plurals> - <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="in_app_screensaver">使用內建螢幕保護程式</string> - <string name="guest_star">客串演員</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="engineer">錄音師</string> - <string name="mixer">混音師</string> - <string name="creator">創作者</string> - <string name="artist">演出者</string> - <string name="delete_item">確定要刪除此項目嗎?</string> - <string name="show_media_management">顯示媒體管理選項</string> - <string name="search_for">搜尋 %s</string> - <string name="select_all">全選</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="go_to_album">前往專輯</string> - <string name="add_to_queue">加入待播佇列</string> - <string name="remove_from_queue">移出待播佇列</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="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="instant_mix">即時混播</string> - <string name="separate_types">依類型分開顯示</string> - <string name="prefer_logos">優先顯示圖像標題</string> - <string name="updated_toast">Wolfskin 已更新至 %s</string> - <string name="view_more">查看更多</string> - <string name="discover_tv">探索電視劇</string> - <string name="discover_movies">探索電影</string> - <string name="remove_continue_watching">從繼續觀看中移除</string> - <string name="remove_next_up">從接下來播放中移除劇集</string> - <string name="view_removed_next_up">查看從接下來播放中移除的劇集</string> - <string name="studios_in">%1$s 製作公司</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="last_played">上次播放</string> - <string name="next_up_never">不顯示</string> - <string name="external_player">外部播放器</string> - <string name="system_default">系統預設</string> - <string name="skip_behavior">跳過行為</string> - <string name="skip_behavior_summary">用於片頭、片尾等段落</string> - <string name="show_more">顯示更多 (%1$s 筆)</string> - <string name="cinema_mode">影院模式</string> - <string name="play_with">播放方式</string> - <string name="transcoding">轉碼</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="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="display_toggles_title">評分顯示</string> - <string name="display_toggles_summary">自訂要顯示的項目</string> - <string name="stars">星級評分</string> - <string name="tomatoes">番茄評分</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="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> - <plurals name="episodes_count"> - <item quantity="other">%1$d 集</item> - </plurals> - <string name="prefer_dovi_over_hdr10_plus">Dolby Vision 優先於 HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+</string> - <string name="max_resolution">最高解析度,超過將轉碼</string> - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">杜比視界 Profile 5</string> - <string name="dovi_profile_7">杜比視界 Profile 7</string> - <string name="dovi_profile_8">杜比視界 Profile 8</string> - <string name="force_enable">強制啟用</string> - <string name="force_disable">強制停用</string> - <string name="server_version_not_supported_future">伺服器版本即將停止支援</string> - <string name="server_version_required">伺服器版本需為:%1$s</string> - <string name="user">使用者</string> - <string name="series">劇集</string> - <string name="load_from_server_plugin">從伺服器載入預設值</string> - <string name="save_to_local">僅儲存於此裝置</string> - <string name="home_settings_source_unset">預設</string> - <string name="top_rated">高評分</string> - <string name="hdr_formats">HDR 格式</string> - <string name="chosen_by">由 %1$s 精選</string> - <string name="convert_dovi_profile_7">將杜比視界 Profile 7 轉換為 8.1</string> - <string name="convert_dovi_profile_7_summary">在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片</string> -</resources> diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 45a6f7b2..ad36a929 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -1,8 +1,5 @@ <?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> @@ -12,22 +9,17 @@ <string name="fa_images" translatable="false"></string> <string name="fa_rotate_right" translatable="false"></string> <string name="fa_rotate_left" translatable="false"></string> - <string name="fa_arrows_rotate" translatable="false"></string> <string name="fa_arrow_right_arrow_left" translatable="false"></string> <string name="fa_magnifying_glass_plus" translatable="false"></string> <string name="fa_magnifying_glass_minus" translatable="false"></string> <string name="fa_caret_up" translatable="false"></string> <string name="fa_caret_down" translatable="false"></string> - <string name="fa_caret_left" translatable="false"></string> - <string name="fa_caret_right" translatable="false"></string> <string name="fa_heart" translatable="false"></string> <string name="fa_video" translatable="false"></string> <string name="fa_play" translatable="false"></string> <string name="fa_pause" translatable="false"></string> <string name="fa_arrow_down_long" translatable="false"></string> <string name="fa_arrow_up_long" translatable="false"></string> - <string name="fa_arrow_left_long" translatable="false"></string> - <string name="fa_arrow_right_long" translatable="false"></string> <string name="fa_angle_up" translatable="false"></string> <string name="fa_angles_up" translatable="false"></string> <string name="fa_angle_down" translatable="false"></string> @@ -48,18 +40,4 @@ <string name="fa_ellipsis" translatable="false"></string> <string name="fa_ellipsis_vertical" translatable="false"></string> <string name="fa_filter" translatable="false"></string> - <string name="fa_sliders" translatable="false"></string> - <string name="fa_microphone" translatable="false"></string> - <string name="fa_download" translatable="false"></string> - <string name="fa_clock" translatable="false"></string> - <string name="fa_bell" translatable="false"></string> - <string name="fa_check" translatable="false"></string> - <string name="fa_cloud_arrow_up" translatable="false"></string> - <string name="fa_cloud_arrow_down" translatable="false"></string> - <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-ja/strings.xml b/app/src/main/res/values/ic_banner_background.xml similarity index 53% rename from app/src/main/res/values-ja/strings.xml rename to app/src/main/res/values/ic_banner_background.xml index 045e125f..e0e3446a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values/ic_banner_background.xml @@ -1,3 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> + <color name="ic_banner_background">#000B25</color> </resources> diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index 933396c0..ffea1b18 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <color name="ic_launcher_background">#07070a</color> + <color name="ic_launcher_background">#101010</color> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1fadeac7..5c92e74e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,14 +25,7 @@ <string name="confirm">Confirm</string> <string name="continue_watching">Continue watching</string> <string name="critic_rating">Critic Rating</string> - <string name="decimal_seconds">%.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="decimal_seconds">%.1f seconds</string> <string name="default_track">Default</string> <string name="delete">Delete</string> <string name="died">Died</string> @@ -43,9 +36,7 @@ <string name="download_and_update"><![CDATA[Download & Update]]></string> <string name="downloading">Downloading…</string> <string name="enabled">Enabled</string> - <string name="ends_at">Ends at %1$s</string> <string name="enter_server_url">Enter Server IP or URL</string> - <string name="enter_server_address">Enter server address</string> <string name="episodes">Episodes</string> <string name="error_loading_collection">Error loading collection %1$s</string> <string name="external_track">External</string> @@ -73,19 +64,17 @@ <string name="max_bitrate">Max bitrate</string> <string name="more_like_this">More like this</string> <string name="more">More</string> - <string name="movies_title">Movies</string> + <string name="movies">Movies</string> <string name="name">Name</string> <string name="next_up">Next Up</string> <string name="no_data">No data</string> <string name="no_results">No results</string> - <string name="no_servers_found">No servers found</string> <string name="no_scheduled_recordings">No scheduled recordings</string> <string name="no_update_available">No update available</string> <string name="none">None</string> - <string name="only_forced_subtitles">Only Forced Subtitles</string> <string name="outro">Outro</string> <string name="path">Path</string> - <string name="people_title">People</string> + <string name="people">People</string> <string name="play_count">Play Count</string> <string name="play_from_here">Play from here</string> <string name="play">Play</string> @@ -96,7 +85,6 @@ <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> @@ -113,24 +101,6 @@ <string name="search_and_download"><![CDATA[Search & Download]]></string> <string name="search">Search</string> <string name="searching">Searching…</string> - <string name="voice_search">Voice search</string> - <string name="voice_starting">Starting</string> - <string name="voice_search_prompt">Speak to search</string> - <string name="processing">Processing</string> - <string name="press_back_to_cancel">Press back to cancel</string> - <string name="voice_error_audio">Audio recording error</string> - <string name="voice_error_client">Voice recognition error</string> - <string name="voice_error_permissions">Microphone permission required</string> - <string name="voice_error_network">Network error</string> - <string name="voice_error_network_timeout">Network timeout</string> - <string name="voice_error_no_match">No speech recognized</string> - <string name="voice_error_busy">Voice recognition busy</string> - <string name="voice_error_server">Server error</string> - <string name="voice_error_speech_timeout">No speech detected</string> - <string name="voice_error_unknown">Unknown error</string> - <string name="voice_error_start_failed">Failed to start voice recognition</string> - <string name="voice_error_timeout">Voice recognition timed out</string> - <string name="retry">Retry</string> <string name="select_server">Select Server</string> <string name="select_user">Select User</string> <string name="show_debug_info">Show debug info</string> @@ -152,22 +122,14 @@ <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="top_rated">Top Rated</string> - <string name="trailers_title">Trailers</string> + <string name="trailer">Trailer</string> + <string name="trailers">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="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="rating_episode_label">Ep</string> - <string name="season_number">Season %1$d</string> - <string name="tv_shows_title">TV Shows</string> + <string name="tv_shows">TV Shows</string> <string name="ui_interface">Interface</string> <string name="unknown">Unknown</string> <string name="updates">Updates</string> @@ -176,13 +138,8 @@ <string name="video">Video</string> <string name="videos">Videos</string> <string name="watch_live">Watch live</string> - <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="years_old">%1$d years old</string> <string name="play_with_transcoding">Play with transcoding</string> - <string name="media_information">Media Information</string> <string name="show_clock">Show Clock</string> <string name="aired_episode_order">Aired Episode Order</string> <string name="create_playlist">Create new playlist</string> @@ -196,19 +153,18 @@ <string name="official_rating">Parental Rating</string> <string name="runtime_sort">Runtime</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> + <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> <plurals name="trailers"> <item quantity="zero">Trailers</item> @@ -281,11 +237,6 @@ <item quantity="one">%d hour</item> <item quantity="other">%d hours</item> </plurals> - <plurals name="days"> - <item quantity="zero">%s days</item> - <item quantity="one">%s day</item> - <item quantity="other">%s days</item> - </plurals> <plurals name="items"> <item quantity="zero">%d items</item> <item quantity="one">%d item</item> @@ -296,27 +247,6 @@ <item quantity="one">%d second</item> <item quantity="other">%d seconds</item> </plurals> - <plurals name="minutes"> - <item quantity="zero">%s minutes</item> - <item quantity="one">%s minute</item> - <item quantity="other">%s minutes</item> - </plurals> - - <plurals name="movies"> - <item quantity="zero">Movies</item> - <item quantity="one">Movie</item> - <item quantity="other">Movies</item> - </plurals> - <plurals name="tv_shows"> - <item quantity="zero">TV Shows</item> - <item quantity="one">TV Show</item> - <item quantity="other">TV Shows</item> - </plurals> - <plurals name="people"> - <item quantity="zero">People</item> - <item quantity="one">Person</item> - <item quantity="other">People</item> - </plurals> <!-- Preferences/Setting specific strings --> <string name="pref_key_update_last_check_threshold" translatable="false">preference.update.threshold</string> @@ -332,7 +262,7 @@ <string name="check_for_updates">Check for updates</string> <string name="combine_continue_next_summary">Applies to TV Series only</string> <string name="combine_continue_next"><![CDATA[Combine Continue Watching & Next Up]]></string> - <string name="direct_play_ass">Use libass for ASS subtitles</string> + <string name="direct_play_ass">Direct play ASS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="downmix_stereo">Always downmix to stereo</string> <string name="ffmpeg_extension_pref">Use FFmpeg decoder module</string> @@ -396,595 +326,79 @@ <string name="remove">Remove</string> <string name="dolby_vision">Dolby Vision</string> <string name="dolby_atmos">Dolby Atmos</string> - <string name="mpv_use_gpu_next">MPV: Use gpu-next</string> - <string name="mpv_conf">Edit mpv.conf</string> - <string name="discard_change">Discard changes?</string> - <string name="subtitle_margin">Margin</string> - <string name="verbose_logging">Verbose logging</string> - <string name="enter_pin">Enter PIN</string> - <string name="sign_in_auto">Sign in automatically</string> - <string name="use_server_credentials">Login via server</string> - <string name="press_enter_to_confirm">Press center to confirm</string> - <string name="require_pin_code">Require PIN for profile</string> - <string name="require_login">Require login for profile</string> - <string name="confirm_pin">Confirm PIN</string> - <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="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="hdr_formats">HDR formats</string> - <string name="hdr10_override">HDR10</string> - <string name="hdr10_plus_override">HDR10+</string> - <string name="dovi_profile_5">Dolby Vision Profile 5</string> - <string name="dovi_profile_7">Dolby Vision Profile 7</string> - <string name="dovi_profile_8">Dolby Vision Profile 8</string> - <string name="force_enable">Force enable</string> - <string name="force_disable">Force disable</string> - <string-array name="hdr_override_modes"> - <item>@string/automatic</item> - <item>@string/force_enable</item> - <item>@string/force_disable</item> - </string-array> - <string name="view_options">View options</string> - <string name="columns">Columns</string> - <string name="spacing">Spacing</string> - <string name="aspect_ratio">Aspect Ratio</string> - <string name="show_details">Show details</string> - <string name="image_type">Image type</string> - <string name="cast_and_crew"><![CDATA[Cast & Crew]]></string> - <string name="guest_stars">Guest stars</string> - <string name="edge_size">Edge size</string> - <string name="refresh_rate_switching">Refresh rate switching</string> - <string name="automatic">Automatic</string> - <string name="username_or_password">Use username/password</string> - <string name="login">Login</string> - <string name="general">General</string> - <string name="container">Container</string> - <string name="title">Title</string> - <string name="codec">Codec</string> - <string name="profile">Profile</string> - <string name="level">Level</string> - <string name="resolution">Resolution</string> - <string name="anamorphic">Anamorphic</string> - <string name="interlaced">Interlaced</string> - <string name="framerate">Framerate</string> - <string name="bit_depth">Bit depth</string> - <string name="video_range">Video range</string> - <string name="video_range_type">Video range type</string> - <string name="color_space">Color space</string> - <string name="color_transfer">Color transfer</string> - <string name="color_primaries">Color primaries</string> - <string name="pixel_format">Pixel format</string> - <string name="ref_frames">Ref frames</string> - <string name="nal">NAL</string> - <string name="language">Language</string> - <string name="layout">Layout</string> - <string name="channels">Channels</string> - <string name="sample_rate">Sample rate</string> - <string name="avc">AVC</string> - <string name="yes">Yes</string> - <string name="no">No</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">Show titles</string> - <string name="live_tv_repeat">Repeat</string> - <string name="favorite_channels_at_beginning">Show favorite channels first</string> - <string name="sort_channels_recently_watched">Sort channels by recently watched</string> - <string name="color_code_programs">Color-code programs</string> - <string name="subtitle_delay">Subtitle delay</string> - <string name="backdrop_display">Backdrop style</string> - <string name="combined_search_results">Combined search results</string> - <string name="combined_search_results_on">Show all results in a single list</string> - <string name="combined_search_results_off">Show results by category</string> - <string name="show_voice_search_button">Show voice search button</string> - <string name="visible_ui">Visible</string> - <string name="hidden_ui">Hidden</string> - <string name="results">Results</string> - <string name="resolution_switching">Resolution switching</string> - <string name="local">Local</string> - <string name="play_trailer">Play trailer</string> - <string name="no_trailers">No trailers</string> - <string name="clear_track_choices">Clear track choices</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">Ignores device compatibility checks</string> - <string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string> - <string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string> - <string name="convert_dovi_profile_7">Convert Dolby Vision Profile 7 to 8.1</string> - <string name="convert_dovi_profile_7_summary">Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7</string> - <string name="discover">Discover</string> - <string name="request">Request</string> - <string name="pending">Pending</string> - <string name="seerr_integration">Seerr integration</string> - <string name="remove_seerr_server">Remove Seerr Server</string> - <string name="seerr_server_added">Seerr server added</string> - <string name="quick_connect">Quick Connect</string> - <string name="quick_connect_summary">Authorize another device to log in to your account</string> - <string name="quick_connect_code">Enter Quick Connect code</string> - <string name="quick_connect_code_error">Code must be 6 digits</string> - <string name="quick_connect_success">Device authorized successfully</string> - <string name="password">Password</string> - <string name="username">Username</string> - <string name="url">URL</string> - <string name="trending">Trending</string> - <string name="upcoming_movies">Upcoming Movies</string> - <string name="upcoming_tv">Upcoming TV Shows</string> - <string name="request_4k">Request in 4K</string> - <string name="software_decoding_av1">AV1 software decoding</string> - <string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string> - <string name="hdr_subtitle_style">HDR subtitle style</string> - <string name="image_subtitle_opacity">Image subtitle opacity</string> - - <string name="brightness">Brightness</string> - <string name="contrast">Contrast</string> - <string name="saturation">Saturation</string> - <string name="hue">Hue</string> - <string name="red">Red</string> - <string name="green">Green</string> - <string name="blue">Blue</string> - <string name="blur">Blur</string> - <string name="save_for_album">Save for album</string> - <string name="play_slideshow">Play slideshow</string> - <string name="stop_slideshow">Stop slideshow</string> - <string name="slideshow_at_beginning">At beginning</string> - <string name="no_more_images">No more photos</string> - <string name="rotate_left">Rotate left</string> - <string name="rotate_right">Rotate right</string> - <string name="zoom_in">Zoom in</string> - <string name="zoom_out">Zoom out</string> - <string name="slideshow_duration">Slideshow duration</string> - <string name="play_videos_during_slideshow">Play videos during slideshow</string> - <string name="send_media_info_log_to_server">Send media info log to server</string> - <string name="no_limit">No limit</string> - <string name="max_days_next_up">Max days in Next Up</string> - <string name="albums">Albums</string> - <string name="artists">Artists</string> - <string name="songs">Songs</string> - <string name="go_to_artist">Go to artist</string> - <string name="now_playing">Now playing</string> - <string name="instant_mix">Instant mix</string> - <string name="go_to_album">Go to album</string> - <string name="add_to_queue">Add to queue</string> - - <string name="add_row">Add row</string> - <string name="genres_in">Genres in %1$s</string> - <string name="recently_released_in">Recently released in %1$s</string> - <string name="height">Height</string> - <string name="apply_all_rows">Apply to all rows</string> - <string name="customize_home">Customize home page</string> - <string name="home_rows">Home rows</string> - <string name="load_from_server">Load from server user profile</string> - <string name="save_to_server">Save to server user profile</string> - <string name="load_from_web_client">Load from web client</string> - <string name="use_series">Use series image</string> - <string name="add_row_for">Add row for %1$s</string> - <string name="overwrite_server_settings">Overwrite settings on server?</string> - <string name="overwrite_local_settings">Overwrite local settings?</string> - <string name="for_episodes">For episodes</string> - <string name="suggestions_for">Suggestions for %1$s</string> - <string name="increase_all_cards_size">Increase size for all cards</string> - <string name="decrease_all_cards_size">Decrease size for all cards</string> - <string name="use_thumb_images">Use thumb images</string> - <string name="settings_saved">Settings saved</string> - <string name="display_presets">Display presets</string> - <string name="display_presets_description">Built-in presets to quickly style all rows</string> - <string name="customize_home_summary">Choose rows and images on the home page</string> - <string name="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> - <string name="no_max">No max</string> - <string name="for_all_ages">For all ages</string> - <string name="up_to_age">Up to age %s</string> - <string name="screensaver">Screensaver</string> - <string name="screensaver_settings">Screensaver settings</string> - <string name="start_screensaver">Start screensaver</string> - <string name="duration">Duration</string> - <string name="photos">Photos</string> - <string name="include_types">Include types</string> - <string name="include_types_summary">What types of items to show images for</string> - <string name="content_scale_fit">Fit</string> - <string name="content_scale_crop">Crop</string> - <string name="content_scale_fill">Fill</string> - <string name="content_scale_fill_width">Fill width</string> - <string name="content_scale_fill_height">Fill height</string> - <string name="display_preset_default">Wholphin Default</string> - <string name="display_preset_compact">Wholphin Compact</string> - <string name="display_preset_series_thumb">Series Thumb images</string> - <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> - - <string name="volume_lowest">Lowest</string> - <string name="volume_low">Low</string> - <string name="volume_medium">Medium</string> - <string name="volume_high">High</string> - <string name="volume_full">Full</string> <string-array name="theme_song_volume"> - <item>@string/disabled</item> - <item>@string/volume_lowest</item> - <item>@string/volume_low</item> - <item>@string/volume_medium</item> - <item>@string/volume_high</item> - <item>@string/volume_full</item> + <item>Disabled</item> + <item>Lowest</item> + <item>Low</item> + <item>Medium</item> + <item>High</item> + <item>Full</item> </string-array> - <string name="purple">Purple</string> - <string name="orange">Orange</string> - <string name="bold_blue">Bold Blue</string> - <string name="black">Black</string> - <string name="brown">Brown</string> <string-array name="app_theme_colors"> - <item>@string/purple</item> - <item>@string/blue</item> - <item>@string/green</item> - <item>@string/orange</item> - <item>@string/bold_blue</item> - <item>@string/black</item> - <item>@string/red</item> - <item>@string/brown</item> + <item>Purple</item> + <item>Blue</item> + <item>Green</item> + <item>Orange</item> + <item>Bold Blue</item> + <item>Black</item> </string-array> - <string name="skip_ignore">Ignore</string> - <string name="skip_automatically">Skip automatically</string> - <string name="skip_ask">Ask to skip</string> <string-array name="skip_behaviors"> - <item>@string/skip_ignore</item> - <item>@string/skip_automatically</item> - <item>@string/skip_ask</item> + <item>Ignore</item> + <item>Skip automatically</item> + <item>Ask to skip</item> </string-array> <string-array name="content_scale"> - <item>@string/content_scale_fit</item> - <item>@string/none</item> - <item>@string/content_scale_crop</item> - <item>@string/content_scale_fill</item> - <item>@string/content_scale_fill_width</item> - <item>@string/content_scale_fill_height</item> + <item>Fit</item> + <item>None</item> + <item>Crop</item> + <item>Fill</item> + <item>Fill Width</item> + <item>Fill Height</item> </string-array> - <string name="ffmpeg_fallback">Only use FFmpeg if no built-in decoder exists</string> - <string name="ffmpeg_prefer">Prefer to use FFmpeg over built-in decoders</string> - <string name="ffmpeg_never">Never use FFmpeg decoders</string> <string-array name="ffmpeg_extension_options"> - <item>@string/ffmpeg_fallback</item> - <item>@string/ffmpeg_prefer</item> - <item>@string/ffmpeg_never</item> + <item>Only use FFmpeg if no built-in decoder exists</item> + <item>Prefer to use FFmpeg over built-in decoders</item> + <item>Never use FFmpeg decoders</item> </string-array> - <string name="next_up_playback_end">At the end of playback</string> - <string name="next_up_outro">During end credits/outro</string> - <string name="next_up_never">Never</string> <string-array name="show_next_up_when_options"> - <item>@string/next_up_playback_end</item> - <item>@string/next_up_outro</item> - <item>@string/next_up_never</item> + <item>At the end of playback</item> + <item>During end credits/outro</item> </string-array> - <string name="white">White</string> - <string name="light_gray">Light gray</string> - <string name="dark_gray">Dark gray</string> - <string name="yellow">Yellow</string> - <string name="cyan">Cyan</string> - <string name="magenta">Magenta</string> <string-array name="font_colors"> - <item>@string/white</item> - <item>@string/black</item> - <item>@string/light_gray</item> - <item>@string/dark_gray</item> - <item>@string/red</item> - <item>@string/yellow</item> - <item>@string/green</item> - <item>@string/cyan</item> - <item>@string/blue</item> - <item>@string/magenta</item> + <item>White</item> + <item>Black</item> + <item>Light Gray</item> + <item>Dark Gray</item> + <item>Red</item> + <item>Yellow</item> + <item>Green</item> + <item>Cyan</item> + <item>Blue</item> + <item>Magenta</item> </string-array> - <string name="subtitle_edge_outline">Outline</string> - <string name="subtitle_edge_shadow">Shadow</string> <string-array name="subtitle_edge"> - <item>@string/none</item> - <item>@string/subtitle_edge_outline</item> - <item>@string/subtitle_edge_shadow</item> + <item>None</item> + <item>Outline</item> + <item>Shadow</item> </string-array> - <string name="background_style_wrap">Wrap</string> - <string name="background_style_boxed">Boxed</string> <string-array name="background_style"> - <item>@string/none</item> - <item>@string/background_style_wrap</item> - <item>@string/background_style_boxed</item> + <item>None</item> + <item>Wrap</item> + <item>Boxed</item> </string-array> - <string name="exoplayer" translatable="false">ExoPlayer</string> - <string name="mpv" translatable="false">MPV</string> - <string name="prefer_mpv">Prefer MPV</string> - <string name="external_player">External Player</string> <string-array name="player_backend_options"> - <item>@string/exoplayer</item> - <item>@string/mpv</item> - <item>@string/prefer_mpv</item> - <item>@string/external_player</item> + <item>ExoPlayer (default)</item> + <item>MPV (Experimental)</item> </string-array> - <string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string> - <string-array name="player_backend_options_subtitles"> - <item /><!-- Intentionally blank --> - <item /><!-- Intentionally blank --> - <item>@string/player_backend_options_subtitles_prefer_mpv</item> - <item /><!-- Intentionally blank --> - </string-array> - - <string name="aspect_ratios_poster">Poster (2:3)</string> - <string name="aspect_ratios_16_9">16:9</string> - <string name="aspect_ratios_4_3">4:3</string> - <string name="aspect_ratios_square">Square (1:1)</string> - <string-array name="aspect_ratios"> - <item>@string/aspect_ratios_poster</item> - <item>@string/aspect_ratios_16_9</item> - <item>@string/aspect_ratios_4_3</item> - <item>@string/aspect_ratios_square</item> - </string-array> - - <string name="image_type_primary">Primary</string> - <string name="image_type_thumb">Thumb</string> - <string-array name="image_types"> - <item>@string/image_type_primary</item> - <item>@string/image_type_thumb</item> - <!-- <item>Banner</item>--> - </string-array> - <string name="view_options_type_grid">Grid</string> - <string name="view_options_type_list">List</string> - <string name="view_options_type_list_compact">Compact List</string> - <string-array name="view_options_types"> - <item>@string/view_options_type_grid</item> - <item>@string/view_options_type_list</item> - <item>@string/view_options_type_list_compact</item> - </string-array> - - <string name="backdrop_style_dynamic">Image with dynamic color</string> - <string name="backdrop_style_image">Image only</string> - <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> - <string name="api_key">API Key</string> - <string name="seerr_login">Login to Seerr server</string> - <string name="seerr_jellyfin_user">Jellyfin user</string> - <string name="seerr_local_user">Local user</string> - <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> - <string name="no_subtitles_found">No remote subtitles were found</string> - <string name="series_continueing">Present</string> - <string name="in_app_screensaver">Use in-app screensaver</string> - <string name="delete_item">Are you sure you want to delete this item?</string> - <string name="show_media_management">Show media management options</string> - <string-array name="backdrop_style_options"> - <item>@string/backdrop_style_dynamic</item> - <item>@string/backdrop_style_image</item> - <item>@string/none</item> - </string-array> - - <string-array name="screensaver_item_types"> - <item>@string/movies_title</item> - <item>@string/tv_shows_title</item> - <item>@string/photos</item> - </string-array> - - <string-array name="display_toggle_types"> - <item>@string/official_rating</item> - <item>@string/critic_rating</item> - <item>@string/community_rating</item> - </string-array> - <string-array name="display_toggle_types_subtitles"> - <item></item><!-- Intentionally blank --> - <item>@string/tomatoes</item> - <item>@string/stars</item> - </string-array> - - <string name="search_for">Search %s</string> - - <string name="actor">Actor</string> - <string name="composer">Composer</string> - <string name="writer">Writer</string> - <string name="guest_star">Guest star</string> - <string name="producer">Producer</string> - <string name="conductor">Conductor</string> - <string name="lyricist">Lyricist</string> - <string name="arranger">Arranger</string> - <string name="engineer">Engineer</string> - <string name="mixer">Mixer</string> - <string name="creator">Creator</string> - <string name="artist">Artist</string> - <string name="album_artist">Album artist</string> - <string name="artist_source">Artist source</string> - <string-array name="artist_sources"> - <item>@string/artist</item> - <item>@string/album_artist</item> - </string-array> - <string name="album">Album</string> - <string name="popular_songs">Popular songs</string> - <string name="play_next">Play next</string> - <string name="music_videos">Music videos</string> - <string name="lyrics">Lyrics</string> - <string name="hide_lyrics">Hide lyrics</string> - <string name="show_lyrics">Show lyrics</string> - <string name="song_has_lyrics">Song has lyrics</string> - <string name="remove_from_queue">Remove from queue</string> - <string name="show_album_cover">Show album cover</string> - <string name="show_visualizer">Show visualizer</string> - <string name="show_backdrop">Show backdrop</string> - <string name="play_as_type">Play as?</string> - <string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string> - <string name="continue_string">Continue</string> - <string name="select_all">Select all</string> - <string name="separate_types">Separate types</string> - <string name="prefer_logos">Prefer showing logos for titles</string> - <string name="updated_toast">Wholphin updated to %s</string> - <string name="remove_continue_watching">Remove from continue watching</string> - <string name="remove_next_up">Remove series from next up</string> - <string name="view_removed_next_up">View series removed from next up</string> - <string name="view_more">View more</string> - <string name="discover_tv">Discover TV Shows</string> - <string name="discover_movies">Discover Movies</string> - <string name="studios_in">Studios in %1$s</string> - <string name="last_played">Last played</string> - <string name="system_default">System default</string> - <string name="ass_subtitle_mode_libass">Direct play with libass</string> - <string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string> - <string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string> - <string name="ass_subtitle_playback">SSA/ASS subtitle playback</string> - <string name="cinema_mode">Cinema mode</string> - <string name="show_more">Show %1$s more</string> - <string name="skip_behavior">Skip behavior</string> - <string name="skip_behavior_summary">For intros, credits, etc</string> - <string name="play_with">Play with</string> - <string name="transcoding">Transcoding</string> - <string name="user_interface_language">User interface language</string> - <string name="display_toggles_title">Ratings display</string> - <string name="display_toggles_summary">Customize which to display</string> - <string name="profile_protection">Profile protection</string> - <string name="continue_watching_click_behavior">Continue watching/Next up click behavior</string> - <string name="continue_watching_click_summary_on">Play on click</string> - <string name="continue_watching_click_summary_off">Show details on click</string> - <array name="ass_subtitle_modes"> - <item>@string/ass_subtitle_mode_libass</item> - <item>@string/ass_subtitle_mode_exoplayer</item> - <item>@string/ass_subtitle_mode_transcode</item> - </array> - <array name="ass_subtitle_modes_summary"> - <item>@string/default_track</item> - <item></item><!-- Intentionally blank --> - <item></item><!-- Intentionally blank --> - </array> - <string name="stars">Stars</string> - <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="server_version_not_supported_future">Server version will not be supported soon</string> - <string name="server_version_required">Required server version: %1$s</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> - <string name="max_resolution">Max resolution before transcoding</string> - - <string name="resolution_8k">8K</string> - <string name="resolution_4k">4K</string> - <string name="resolution_1080">1080</string> - <string name="resolution_720">720</string> - <string name="resolution_576">576</string> - <string name="resolution_480">480</string> - <string name="resolution_360">360</string> - <string-array name="max_resolution_options"> - <item>@string/no_limit</item> - <item>@string/resolution_8k</item> - <item>@string/resolution_4k</item> - <item>@string/resolution_1080</item> - <item>@string/resolution_720</item> - <item>@string/resolution_576</item> - <item>@string/resolution_480</item> - <item>@string/resolution_360</item> - </string-array> - <plurals name="episodes_count"> - <item quantity="zero">%1$d Episodes</item> - <item quantity="one">%1$d Episode</item> - <item quantity="other">%1$d Episodes</item> - </plurals> - <string name="chosen_by">Chosen by %1$s</string> - <string name="user">User</string> - <string name="series">Series</string> - <string name="load_from_server_plugin">Load default from server</string> - <string name="save_to_local">Save only to this device</string> - <string name="home_settings_source_unset">Default</string> - <string name="home_settings_source_local">Device specific</string> - <string name="home_settings_source_server_profile">Server user profile</string> - <string name="home_settings_source_plugin">Server default</string> - <string name="home_settings_source_web">User web client</string> - <string name="managed_by_server">Managed by server</string> - <string name="error">Error</string> </resources> diff --git a/app/src/main/res/values/wolfskin.xml b/app/src/main/res/values/wolfskin.xml deleted file mode 100644 index 0308ae9e..00000000 --- a/app/src/main/res/values/wolfskin.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="wolfskin_screens" translatable="false">Wolfskin screens</string> - <string name="wolfskin_upstream_credit" translatable="false">based on Wholphin by damontecres</string> - <string name="wolfskin_upstream_url" translatable="false">https://github.com/damontecres/Wholphin</string> -</resources> diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index 115a8e89..00000000 --- a/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,9 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<network-security-config> - <base-config cleartextTrafficPermitted="true"> - <trust-anchors> - <certificates src="system"/> - <certificates src="user"/> - </trust-anchors> - </base-config> -</network-security-config> diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml deleted file mode 100644 index dc493638..00000000 --- a/app/src/main/seerr/seerr-api.yml +++ /dev/null @@ -1,7886 +0,0 @@ -openapi: '3.0.2' -info: - title: 'Seerr API' - version: '1.0.0' - description: | - This is the documentation for the Seerr API backend. - - Two primary authentication methods are supported: - - - **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie. - - **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr. -tags: - - name: public - description: Public API endpoints requiring no authentication. - - name: settings - description: Endpoints related to Seerr's settings and configuration. - - name: auth - description: Endpoints related to logging in or out, and the currently authenticated user. - - name: users - description: Endpoints related to user management. - - name: search - description: Endpoints related to search and discovery. - - name: request - description: Endpoints related to request management. - - name: movies - description: Endpoints related to retrieving movies and their details. - - name: tv - description: Endpoints related to retrieving TV series and their details. - - name: other - description: Endpoints related to other TMDB data - - name: person - description: Endpoints related to retrieving person details. - - name: media - description: Endpoints related to media management. - - name: collection - description: Endpoints related to retrieving collection details. - - name: service - description: Endpoints related to getting service (Radarr/Sonarr) details. - - name: watchlist - description: Collection of media to watch later - - name: blacklist - description: Blacklisted media from discovery page. -servers: - - url: '{server}/api/v1' - variables: - server: - default: http://localhost:5055 - -components: - schemas: - Blacklist: - type: object - properties: - tmdbId: - type: integer - example: 1 - title: - type: string - media: - $ref: '#/components/schemas/MediaInfo' - userId: - type: integer - example: 1 - Watchlist: - type: object - properties: - id: - type: integer - example: 1 - readOnly: true - tmdbId: - type: integer - example: 1 - ratingKey: - type: string - type: - type: string - title: - type: string - media: - $ref: '#/components/schemas/MediaInfo' - createdAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - updatedAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - requestedBy: - $ref: '#/components/schemas/User' - User: - type: object - properties: - id: - type: integer - example: 1 - readOnly: true - email: - type: string - example: 'hey@itsme.com' - readOnly: true - username: - type: string - plexUsername: - type: string - readOnly: true - plexToken: - type: string - readOnly: true - jellyfinAuthToken: - type: string - readOnly: true - userType: - type: integer - example: 1 - readOnly: true - permissions: - type: integer - example: 0 - avatar: - type: string - readOnly: true - createdAt: - type: string - example: '2020-09-02T05:02:23.000Z' - readOnly: true - updatedAt: - type: string - example: '2020-09-02T05:02:23.000Z' - readOnly: true - requestCount: - type: integer - example: 5 - readOnly: true - required: - - id - UserSettings: - type: object - properties: - username: - type: string - nullable: true - example: 'Mr User' - email: - type: string - example: 'user@example.com' - discordId: - type: string - nullable: true - example: '123456789' - locale: - type: string - nullable: true - example: 'en' - discoverRegion: - type: string - nullable: true - example: 'US' - streamingRegion: - type: string - nullable: true - example: 'US' - originalLanguage: - type: string - nullable: true - example: 'en' - movieQuotaLimit: - type: integer - nullable: true - description: 'Maximum number of movie requests allowed' - example: 10 - movieQuotaDays: - type: integer - nullable: true - description: 'Time period in days for movie quota' - example: 30 - tvQuotaLimit: - type: integer - nullable: true - description: 'Maximum number of TV requests allowed' - example: 5 - tvQuotaDays: - type: integer - nullable: true - description: 'Time period in days for TV quota' - example: 14 - globalMovieQuotaDays: - type: integer - nullable: true - description: 'Global movie quota days setting' - example: 30 - globalMovieQuotaLimit: - type: integer - nullable: true - description: 'Global movie quota limit setting' - example: 10 - globalTvQuotaLimit: - type: integer - nullable: true - description: 'Global TV quota limit setting' - example: 5 - globalTvQuotaDays: - type: integer - nullable: true - description: 'Global TV quota days setting' - example: 14 - watchlistSyncMovies: - type: boolean - nullable: true - description: 'Enable watchlist sync for movies' - example: true - watchlistSyncTv: - type: boolean - nullable: true - description: 'Enable watchlist sync for TV' - example: false - MainSettings: - type: object - properties: - apiKey: - type: string - readOnly: true - appLanguage: - type: string - example: en - applicationTitle: - type: string - example: Seerr - applicationUrl: - type: string - example: https://os.example.com - hideAvailable: - type: boolean - example: false - partialRequestsEnabled: - type: boolean - example: false - localLogin: - type: boolean - example: true - mediaServerType: - type: integer - example: 1 - newPlexLogin: - type: boolean - example: true - defaultPermissions: - type: integer - example: 32 - enableSpecialEpisodes: - type: boolean - example: false - NetworkSettings: - type: object - properties: - csrfProtection: - type: boolean - example: false - forceIpv4First: - type: boolean - example: false - trustProxy: - type: boolean - example: false - proxy: - type: object - properties: - enabled: - type: boolean - example: false - hostname: - type: string - example: '' - port: - type: integer - example: 8080 - useSsl: - type: boolean - example: false - user: - type: string - example: '' - password: - type: string - example: '' - bypassFilter: - type: string - example: '' - bypassLocalAddresses: - type: boolean - example: true - dnsCache: - type: object - properties: - enabled: - type: boolean - example: false - forceMinTtl: - type: integer - example: 0 - forceMaxTtl: - type: integer - example: -1 - PlexLibrary: - type: object - properties: - id: - type: string - name: - type: string - example: Movies - enabled: - type: boolean - example: false - required: - - id - - name - - enabled - PlexSettings: - type: object - properties: - name: - type: string - example: 'Main Server' - readOnly: true - machineId: - type: string - example: '1234123412341234' - readOnly: true - ip: - type: string - example: '127.0.0.1' - port: - type: integer - example: 32400 - useSsl: - type: boolean - nullable: true - libraries: - type: array - readOnly: true - items: - $ref: '#/components/schemas/PlexLibrary' - webAppUrl: - type: string - nullable: true - example: 'https://app.plex.tv/desktop' - required: - - name - - machineId - - ip - - port - PlexConnection: - type: object - properties: - protocol: - type: string - example: 'https' - address: - type: string - example: '127.0.0.1' - port: - type: integer - example: 32400 - uri: - type: string - example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' - local: - type: boolean - example: true - status: - type: integer - example: 200 - message: - type: string - example: 'OK' - required: - - protocol - - address - - port - - uri - - local - PlexDevice: - type: object - properties: - name: - type: string - example: 'My Plex Server' - product: - type: string - example: 'Plex Media Server' - productVersion: - type: string - example: '1.21' - platform: - type: string - example: 'Linux' - platformVersion: - type: string - example: 'default/linux/amd64/17.1/systemd' - device: - type: string - example: 'PC' - clientIdentifier: - type: string - example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' - createdAt: - type: string - example: '2021-01-01T00:00:00.000Z' - lastSeenAt: - type: string - example: '2021-01-01T00:00:00.000Z' - provides: - type: array - items: - type: string - example: 'server' - owned: - type: boolean - example: true - ownerID: - type: string - example: '12345' - home: - type: boolean - example: true - sourceTitle: - type: string - example: 'xyzabc' - accessToken: - type: string - example: 'supersecretaccesstoken' - publicAddress: - type: string - example: '127.0.0.1' - httpsRequired: - type: boolean - example: true - synced: - type: boolean - example: true - relay: - type: boolean - example: true - dnsRebindingProtection: - type: boolean - example: false - natLoopbackSupported: - type: boolean - example: false - publicAddressMatches: - type: boolean - example: false - presence: - type: boolean - example: true - connection: - type: array - items: - $ref: '#/components/schemas/PlexConnection' - required: - - name - - product - - productVersion - - platform - - device - - clientIdentifier - - createdAt - - lastSeenAt - - provides - - owned - - connection - JellyfinLibrary: - type: object - properties: - id: - type: string - name: - type: string - example: Movies - enabled: - type: boolean - example: false - required: - - id - - name - - enabled - JellyfinSettings: - type: object - properties: - name: - type: string - example: 'Main Server' - readOnly: true - hostname: - type: string - example: 'http://my.jellyfin.host' - externalHostname: - type: string - example: 'http://my.jellyfin.host' - jellyfinForgotPasswordUrl: - type: string - example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' - adminUser: - type: string - example: 'admin' - adminPass: - type: string - example: 'mypassword' - libraries: - type: array - readOnly: true - items: - $ref: '#/components/schemas/JellyfinLibrary' - serverID: - type: string - readOnly: true - MetadataSettings: - type: object - properties: - settings: - type: object - properties: - tv: - type: string - enum: [tvdb, tmdb] - example: 'tvdb' - anime: - type: string - enum: [tvdb, tmdb] - example: 'tvdb' - TautulliSettings: - type: object - properties: - hostname: - type: string - nullable: true - example: 'tautulli.example.com' - port: - type: integer - nullable: true - example: 8181 - useSsl: - type: boolean - nullable: true - apiKey: - type: string - nullable: true - externalUrl: - type: string - nullable: true - RadarrSettings: - type: object - properties: - id: - type: integer - example: 0 - readOnly: true - name: - type: string - example: 'Radarr Main' - hostname: - type: string - example: '127.0.0.1' - port: - type: integer - example: 7878 - apiKey: - type: string - example: 'exampleapikey' - useSsl: - type: boolean - example: false - baseUrl: - type: string - activeProfileId: - type: integer - example: 1 - activeProfileName: - type: string - example: 720p/1080p - activeDirectory: - type: string - example: '/movies' - is4k: - type: boolean - example: false - minimumAvailability: - type: string - example: 'In Cinema' - isDefault: - type: boolean - example: false - externalUrl: - type: string - example: http://radarr.example.com - syncEnabled: - type: boolean - example: false - preventSearch: - type: boolean - example: false - required: - - name - # - hostname - # - port - # - useSsl - - activeProfileId - # - activeProfileName - - activeDirectory - - is4k - # - minimumAvailability - - isDefault - SonarrSettings: - type: object - properties: - id: - type: integer - example: 0 - readOnly: true - name: - type: string - example: 'Sonarr Main' - hostname: - type: string - example: '127.0.0.1' - port: - type: integer - example: 8989 - apiKey: - type: string - example: 'exampleapikey' - useSsl: - type: boolean - example: false - baseUrl: - type: string - activeProfileId: - type: integer - example: 1 - activeProfileName: - type: string - example: 720p/1080p - activeDirectory: - type: string - example: '/tv/' - activeLanguageProfileId: - type: integer - example: 1 - activeAnimeProfileId: - type: integer - nullable: true - activeAnimeLanguageProfileId: - type: integer - nullable: true - activeAnimeProfileName: - type: string - example: 720p/1080p - nullable: true - activeAnimeDirectory: - type: string - nullable: true - is4k: - type: boolean - example: false - enableSeasonFolders: - type: boolean - example: false - isDefault: - type: boolean - example: false - externalUrl: - type: string - example: http://radarr.example.com - syncEnabled: - type: boolean - example: false - preventSearch: - type: boolean - example: false - required: - - name - # - hostname - # - port - # - apiKey - # - useSsl - - activeProfileId - # - activeProfileName - - activeDirectory - - is4k - # - enableSeasonFolders - - isDefault - ServarrTag: - type: object - properties: - id: - type: integer - example: 1 - label: - type: string - example: A Label - PublicSettings: - type: object - properties: - initialized: - type: boolean - example: false - movie4kEnabled: - type: boolean - series4kEnabled: - type: boolean - cacheImages: - type: boolean - MovieResult: - type: object - required: - - id - - mediaType - # - title - properties: - id: - type: integer - example: 1234 - mediaType: - type: string - popularity: - type: number - example: 10 - posterPath: - type: string - backdropPath: - type: string - voteCount: - type: integer - voteAverage: - type: number - genreIds: - type: array - items: - type: integer - overview: - type: string - example: 'Overview of the movie' - originalLanguage: - type: string - example: 'en' - title: - type: string - example: Movie Title - originalTitle: - type: string - example: Original Movie Title - releaseDate: - type: string - adult: - type: boolean - example: false - video: - type: boolean - example: false - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - TvResult: - type: object - properties: - id: - type: integer - example: 1234 - mediaType: - type: string - popularity: - type: number - example: 10 - posterPath: - type: string - backdropPath: - type: string - voteCount: - type: integer - voteAverage: - type: number - genreIds: - type: array - items: - type: integer - overview: - type: string - example: 'Overview of the movie' - originalLanguage: - type: string - example: 'en' - name: - type: string - example: TV Show Name - originalName: - type: string - example: Original TV Show Name - originCountry: - type: array - items: - type: string - firstAirDate: - type: string - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - PersonResult: - type: object - properties: - id: - type: integer - example: 12345 - profilePath: - type: string - adult: - type: boolean - example: false - mediaType: - type: string - default: 'person' - knownFor: - type: array - items: - oneOf: - - $ref: '#/components/schemas/MovieResult' - - $ref: '#/components/schemas/TvResult' - Genre: - type: object - properties: - id: - type: integer - example: 1 - name: - type: string - example: Adventure - Company: - type: object - properties: - id: - type: integer - example: 1 - logo_path: - type: string - nullable: true - name: - type: string - ProductionCompany: - type: object - properties: - id: - type: integer - example: 1 - logoPath: - type: string - nullable: true - originCountry: - type: string - name: - type: string - Network: - type: object - properties: - id: - type: integer - example: 1 - logoPath: - type: string - nullable: true - originCountry: - type: string - name: - type: string - RelatedVideo: - type: object - properties: - url: - type: string - example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ - key: - type: string - example: 9qhL2_UxXM0 - name: - type: string - example: Trailer for some movie (1978) - size: - type: integer - example: 1080 - type: - type: string - example: Trailer - enum: - - Clip - - Teaser - - Trailer - - Featurette - - Opening Credits - - Behind the Scenes - - Bloopers - site: - type: string - MovieDetails: - type: object - properties: - id: - type: integer - example: 123 - readOnly: true - imdbId: - type: string - example: 'tt123' - adult: - type: boolean - backdropPath: - type: string - posterPath: - type: string - budget: - type: integer - example: 1000000 - genres: - type: array - items: - $ref: '#/components/schemas/Genre' - homepage: - type: string - relatedVideos: - type: array - items: - $ref: '#/components/schemas/RelatedVideo' - originalLanguage: - type: string - originalTitle: - type: string - overview: - type: string - popularity: - type: number - productionCompanies: - type: array - items: - $ref: '#/components/schemas/ProductionCompany' - productionCountries: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - name: - type: string - releaseDate: - type: string - releases: - type: object - properties: - results: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - example: 'US' - rating: - type: string - nullable: true - release_dates: - type: array - items: - type: object - properties: - certification: - type: string - example: 'PG-13' - iso_639_1: - type: string - nullable: true - note: - type: string - nullable: true - example: 'Blu ray' - release_date: - type: string - example: '2017-07-12T00:00:00.000Z' - type: - type: integer - example: 1 - revenue: - type: number - nullable: true - runtime: - type: number - spokenLanguages: - type: array - items: - $ref: '#/components/schemas/SpokenLanguage' - status: - type: string - tagline: - type: string - title: - type: string - video: - type: boolean - voteAverage: - type: number - voteCount: - type: integer - credits: - type: object - properties: - cast: - type: array - items: - $ref: '#/components/schemas/CreditCast' - crew: - type: array - items: - $ref: '#/components/schemas/CreditCrew' - collection: - type: object - properties: - id: - type: integer - example: 1 - name: - type: string - example: A collection - posterPath: - type: string - backdropPath: - type: string - externalIds: - $ref: '#/components/schemas/ExternalIds' - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - watchProviders: - type: array - items: - $ref: '#/components/schemas/WatchProviders' - Episode: - type: object - properties: - id: - type: integer - name: - type: string - airDate: - type: string - nullable: true - episodeNumber: - type: integer - overview: - type: string - productionCode: - type: string - seasonNumber: - type: integer - showId: - type: integer - stillPath: - type: string - nullable: true - voteAverage: - type: number - voteCount: - type: integer - Season: - type: object - properties: - id: - type: integer - airDate: - type: string - nullable: true - episodeCount: - type: integer - name: - type: string - overview: - type: string - posterPath: - type: string - seasonNumber: - type: integer - episodes: - type: array - items: - $ref: '#/components/schemas/Episode' - status: - type: integer - example: 0 - description: Availability of the season - readOnly: true - status4k: - type: integer - example: 0 - description: Availability of the season in 4K - readOnly: true - SeasonDetails: - type: object - description: The answer of the season endpoint. Everything TMDB may not know is nullable. - properties: - id: - type: integer - airDate: - type: string - nullable: true - name: - type: string - nullable: true - overview: - type: string - nullable: true - posterPath: - type: string - nullable: true - seasonNumber: - type: integer - episodes: - type: array - items: - $ref: '#/components/schemas/SeasonEpisode' - SeasonEpisode: - type: object - description: An episode of the season endpoint. voteAverage is TMDB's user score as a decimal, 0 when nobody voted. - properties: - id: - type: integer - name: - type: string - nullable: true - overview: - type: string - nullable: true - airDate: - type: string - nullable: true - episodeNumber: - type: integer - seasonNumber: - type: integer - showId: - type: integer - stillPath: - type: string - nullable: true - voteAverage: - type: number - nullable: true - voteCount: - type: integer - format: int64 - nullable: true - TvShowStatus: - type: string - enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] - TvDetails: - type: object - properties: - id: - type: integer - example: 123 - backdropPath: - type: string - posterPath: - type: string - contentRatings: - type: object - properties: - results: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - example: 'US' - rating: - type: string - example: 'TV-14' - createdBy: - type: array - items: - type: object - properties: - id: - type: integer - name: - type: string - gender: - type: integer - profilePath: - type: string - nullable: true - episodeRunTime: - type: array - items: - type: integer - firstAirDate: - type: string - genres: - type: array - items: - $ref: '#/components/schemas/Genre' - homepage: - type: string - inProduction: - type: boolean - languages: - type: array - items: - type: string - lastAirDate: - type: string - lastEpisodeToAir: - $ref: '#/components/schemas/Episode' - name: - type: string - nextEpisodeToAir: - $ref: '#/components/schemas/Episode' - networks: - type: array - items: - $ref: '#/components/schemas/ProductionCompany' - numberOfEpisodes: - type: integer - numberOfSeason: - type: integer - originCountry: - type: array - items: - type: string - originalLanguage: - type: string - originalName: - type: string - overview: - type: string - popularity: - type: number - productionCompanies: - type: array - items: - $ref: '#/components/schemas/ProductionCompany' - productionCountries: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - name: - type: string - spokenLanguages: - type: array - items: - $ref: '#/components/schemas/SpokenLanguage' - seasons: - type: array - items: - $ref: '#/components/schemas/Season' - tagline: - type: string - type: - type: string - voteAverage: - type: number - voteCount: - type: integer - credits: - type: object - properties: - cast: - type: array - items: - $ref: '#/components/schemas/CreditCast' - crew: - type: array - items: - $ref: '#/components/schemas/CreditCrew' - externalIds: - $ref: '#/components/schemas/ExternalIds' - keywords: - type: array - items: - $ref: '#/components/schemas/Keyword' - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - watchProviders: - type: array - items: - $ref: '#/components/schemas/WatchProviders' - relatedVideos: - type: array - items: - $ref: '#/components/schemas/RelatedVideo' - MediaRequest: - type: object - properties: - id: - type: integer - example: 123 - readOnly: true - status: - type: integer - example: 0 - description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED - readOnly: true - media: - $ref: '#/components/schemas/MediaInfo' - createdAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - updatedAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - requestedBy: - $ref: '#/components/schemas/User' - modifiedBy: - anyOf: - - $ref: '#/components/schemas/User' - - type: string - nullable: true - is4k: - type: boolean - example: false - serverid: - type: integer - profileid: - type: integer - rootFolder: - type: string - type: - type: string - seasons: - type: array - items: - $ref: '#/components/schemas/Season' - required: - - id - - status - MediaInfo: - type: object - properties: - id: - type: integer - readOnly: true - tmdbId: - type: integer - readOnly: true - tvdbId: - type: integer - readOnly: true - nullable: true - status: - 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 - items: - $ref: '#/components/schemas/MediaRequest' - createdAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - updatedAt: - type: string - example: '2020-09-12T10:00:27.000Z' - readOnly: true - jellyfinMediaId: - type: string - jellyfinMediaId4k: - type: string - Cast: - type: object - properties: - id: - type: integer - example: 123 - castid: - type: integer - example: 1 - character: - type: string - example: Some Character Name - creditId: - type: string - gender: - type: integer - name: - type: string - example: Some Persons Name - order: - type: integer - profilePath: - type: string - nullable: true - Crew: - type: object - properties: - id: - type: integer - example: 123 - creditId: - type: string - gender: - type: integer - name: - type: string - example: Some Persons Name - job: - type: string - department: - type: string - profilePath: - type: string - nullable: true - ExternalIds: - type: object - properties: - facebookId: - type: string - nullable: true - freebaseId: - type: string - nullable: true - freebaseMid: - type: string - nullable: true - imdbId: - type: string - nullable: true - instagramId: - type: string - nullable: true - tvdbid: - type: integer - nullable: true - tvrageid: - type: integer - nullable: true - twitterId: - type: string - nullable: true - ServiceProfile: - type: object - properties: - id: - type: integer - example: 1 - 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: - page: - type: integer - example: 1 - pages: - type: integer - example: 10 - results: - type: integer - example: 100 - DiscordSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - botUsername: - type: string - botAvatarUrl: - type: string - webhookUrl: - type: string - webhookRoleId: - type: string - enableMentions: - type: boolean - SlackSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - webhookUrl: - type: string - WebPushSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - WebhookSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - webhookUrl: - type: string - authHeader: - type: string - jsonPayload: - type: string - supportVariables: - type: boolean - example: false - TelegramSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - botUsername: - type: string - botAPI: - type: string - chatId: - type: string - messageThreadId: - type: string - sendSilently: - type: boolean - PushbulletSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - accessToken: - type: string - channelTag: - type: string - nullable: true - PushoverSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - accessToken: - type: string - userToken: - type: string - sound: - type: string - GotifySettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - url: - type: string - token: - type: string - NtfySettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - url: - type: string - topic: - type: string - authMethodUsernamePassword: - type: boolean - username: - type: string - password: - type: string - authMethodToken: - type: boolean - token: - type: string - NotificationEmailSettings: - type: object - properties: - enabled: - type: boolean - example: false - types: - type: integer - example: 2 - options: - type: object - properties: - emailFrom: - type: string - example: no-reply@example.com - senderName: - type: string - example: Seerr - smtpHost: - type: string - example: 127.0.0.1 - smtpPort: - type: integer - example: 465 - secure: - type: boolean - example: false - ignoreTls: - type: boolean - example: false - requireTls: - type: boolean - example: false - authUser: - type: string - nullable: true - authPass: - type: string - nullable: true - allowSelfSigned: - type: boolean - example: false - Job: - type: object - properties: - id: - type: string - example: job-name - type: - type: string - enum: [process, command] - interval: - type: string - enum: [short, long, fixed] - name: - type: string - example: A Job Name - nextExecutionTime: - type: string - example: '2020-09-02T05:02:23.000Z' - running: - type: boolean - example: false - PersonDetails: - type: object - properties: - id: - type: integer - example: 1 - name: - type: string - deathday: - type: string - knownForDepartment: - type: string - alsoKnownAs: - type: array - items: - type: string - gender: - type: string - biography: - type: string - popularity: - type: string - placeOfBirth: - type: string - profilePath: - type: string - adult: - type: boolean - imdbId: - type: string - homepage: - type: string - CreditCast: - type: object - properties: - id: - type: integer - example: 1 - originalLanguage: - type: string - episodeCount: - type: integer - overview: - type: string - originCountry: - type: array - items: - type: string - originalName: - type: string - voteCount: - type: integer - name: - type: string - mediaType: - type: string - popularity: - type: number - creditId: - type: string - backdropPath: - type: string - firstAirDate: - type: string - voteAverage: - type: number - genreIds: - type: array - items: - type: integer - posterPath: - type: string - profilePath: - type: string - nullable: true - originalTitle: - type: string - video: - type: boolean - title: - type: string - adult: - type: boolean - releaseDate: - type: string - character: - type: string - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - CreditCrew: - type: object - properties: - id: - type: integer - example: 1 - originalLanguage: - type: string - episodeCount: - type: integer - overview: - type: string - originCountry: - type: array - items: - type: string - originalName: - type: string - voteCount: - type: integer - name: - type: string - mediaType: - type: string - popularity: - type: number - creditId: - type: string - backdropPath: - type: string - firstAirDate: - type: string - voteAverage: - type: number - genreIds: - type: array - items: - type: integer - posterPath: - type: string - profilePath: - type: string - nullable: true - originalTitle: - type: string - video: - type: boolean - title: - type: string - adult: - type: boolean - releaseDate: - type: string - department: - type: string - job: - type: string - mediaInfo: - $ref: '#/components/schemas/MediaInfo' - Keyword: - type: object - properties: - id: - type: integer - example: 1 - name: - type: string - example: 'anime' - SpokenLanguage: - type: object - properties: - englishName: - type: string - example: 'English' - nullable: true - iso_639_1: - type: string - example: 'en' - name: - type: string - example: 'English' - Collection: - type: object - properties: - id: - type: integer - example: 123 - name: - type: string - example: A Movie Collection - overview: - type: string - example: Overview of collection - posterPath: - type: string - backdropPath: - type: string - parts: - type: array - items: - $ref: '#/components/schemas/MovieResult' - SonarrSeries: - type: object - properties: - title: - type: string - example: COVID-25 - sortTitle: - type: string - example: covid 25 - seasonCount: - type: integer - example: 1 - status: - type: string - example: upcoming - overview: - type: string - example: The thread is picked up again by Marianne Schmidt which ... - network: - type: string - example: CBS - airTime: - type: string - example: 02:15 - images: - type: array - items: - type: object - properties: - coverType: - type: string - example: banner - url: - type: string - example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg - remotePoster: - type: string - example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg - seasons: - type: array - items: - type: object - properties: - seasonNumber: - type: integer - example: 1 - monitored: - type: boolean - example: true - year: - type: integer - example: 2015 - path: - type: string - profileid: - type: integer - languageProfileid: - type: integer - seasonFolder: - type: boolean - monitored: - type: boolean - useSceneNumbering: - type: boolean - runtime: - type: number - tvdbid: - type: integer - example: 12345 - tvRageid: - type: integer - tvMazeid: - type: integer - firstAired: - type: string - lastInfoSync: - type: string - nullable: true - seriesType: - type: string - cleanTitle: - type: string - imdbId: - type: string - titleSlug: - type: string - certification: - type: string - genres: - type: array - items: - type: string - tags: - type: array - items: - type: string - added: - type: string - ratings: - type: array - items: - type: object - properties: - votes: - type: integer - value: - type: integer - qualityProfileid: - type: integer - id: - type: integer - nullable: true - rootFolderPath: - type: string - nullable: true - addOptions: - type: array - items: - type: object - properties: - ignoreEpisodesWithFiles: - type: boolean - nullable: true - ignoreEpisodesWithoutFiles: - type: boolean - nullable: true - searchForMissingEpisodes: - type: boolean - nullable: true - UserSettingsNotifications: - type: object - properties: - notificationTypes: - $ref: '#/components/schemas/NotificationAgentTypes' - emailEnabled: - type: boolean - pgpKey: - type: string - nullable: true - discordEnabled: - type: boolean - discordEnabledTypes: - type: integer - nullable: true - discordId: - type: string - nullable: true - pushbulletAccessToken: - type: string - nullable: true - pushoverApplicationToken: - type: string - nullable: true - pushoverUserKey: - type: string - nullable: true - pushoverSound: - type: string - nullable: true - telegramEnabled: - type: boolean - telegramBotUsername: - type: string - nullable: true - telegramChatId: - type: string - nullable: true - telegramMessageThreadId: - type: string - nullable: true - telegramSendSilently: - type: boolean - nullable: true - NotificationAgentTypes: - type: object - properties: - discord: - type: integer - email: - type: integer - pushbullet: - type: integer - pushover: - type: integer - slack: - type: integer - telegram: - type: integer - webhook: - type: integer - webpush: - type: integer - WatchProviders: - type: object - properties: - iso_3166_1: - type: string - link: - type: string - buy: - type: array - items: - $ref: '#/components/schemas/WatchProviderDetails' - flatrate: - items: - $ref: '#/components/schemas/WatchProviderDetails' - WatchProviderDetails: - type: object - properties: - displayPriority: - type: integer - logoPath: - type: string - id: - type: integer - name: - type: string - Issue: - type: object - properties: - id: - type: integer - example: 1 - issueType: - type: integer - example: 1 - media: - $ref: '#/components/schemas/MediaInfo' - createdBy: - $ref: '#/components/schemas/User' - modifiedBy: - $ref: '#/components/schemas/User' - comments: - type: array - items: - $ref: '#/components/schemas/IssueComment' - IssueComment: - type: object - properties: - id: - type: integer - example: 1 - user: - $ref: '#/components/schemas/User' - message: - type: string - example: A comment - DiscoverSlider: - type: object - properties: - id: - type: integer - example: 1 - type: - type: integer - example: 1 - title: - type: string - nullable: true - isBuiltIn: - type: boolean - enabled: - type: boolean - data: - type: string - example: '1234' - nullable: true - required: - - type - - enabled - - title - - data - WatchProviderRegion: - type: object - properties: - iso_3166_1: - type: string - english_name: - type: string - native_name: - type: string - OverrideRule: - type: object - properties: - id: - type: string - Certification: - type: object - properties: - certification: - type: string - example: 'PG-13' - meaning: - type: string - example: 'Some material may be inappropriate for children under 13.' - nullable: true - order: - type: integer - example: 3 - nullable: true - required: - - certification - - CertificationResponse: - type: object - properties: - certifications: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/Certification' - example: - certifications: - US: - - certification: 'G' - meaning: 'All ages admitted' - order: 1 - - certification: 'PG' - meaning: 'Some material may not be suitable for children under 10.' - order: 2 - securitySchemes: - cookieAuth: - type: apiKey - name: connect.sid - in: cookie - apiKey: - type: apiKey - in: header - name: X-Api-Key - -paths: - /status: - get: - summary: Get Seerr status - description: Returns the current Seerr status in a JSON object. - security: [] - tags: - - public - responses: - '200': - description: Returned status - content: - application/json: - schema: - type: object - properties: - version: - type: string - example: 1.0.0 - commitTag: - type: string - updateAvailable: - type: boolean - commitsBehind: - type: integer - restartRequired: - type: boolean - /status/appdata: - get: - summary: Get application data volume status - description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. - security: [] - tags: - - public - responses: - '200': - description: Application data volume status and path - content: - application/json: - schema: - type: object - properties: - appData: - type: boolean - example: true - appDataPath: - type: string - example: /app/config - appDataPermissions: - type: boolean - example: true - /settings/main: - get: - summary: Get main settings - description: Retrieves all main settings in a JSON object. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MainSettings' - post: - summary: Update main settings - description: Updates main settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MainSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/MainSettings' - /settings/network: - get: - summary: Get network settings - description: Retrieves all network settings in a JSON object. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MainSettings' - post: - summary: Update network settings - description: Updates network settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/NetworkSettings' - /settings/main/regenerate: - post: - summary: Get main settings with newly-generated API key - description: Returns main settings in a JSON object, using the new API key. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MainSettings' - /settings/jellyfin: - get: - summary: Get Jellyfin settings - description: Retrieves current Jellyfin settings. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/JellyfinSettings' - post: - summary: Update Jellyfin settings - description: Updates Jellyfin settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/JellyfinSettings' - responses: - '200': - description: 'Values were successfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/JellyfinSettings' - /settings/jellyfin/library: - get: - summary: Get Jellyfin libraries - description: Returns a list of Jellyfin libraries in a JSON array. - tags: - - settings - parameters: - - in: query - name: sync - description: Syncs the current libraries with the current Jellyfin server - schema: - type: string - nullable: true - - in: query - name: enable - explode: false - allowReserved: true - description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! - schema: - type: string - nullable: true - responses: - '200': - description: 'Jellyfin libraries returned' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/JellyfinLibrary' - /settings/jellyfin/users: - get: - summary: Get Jellyfin Users - description: Returns a list of Jellyfin Users in a JSON array. - tags: - - settings - - users - responses: - '200': - description: Jellyfin users returned - content: - application/json: - schema: - type: array - items: - type: object - properties: - username: - type: string - id: - type: string - thumb: - type: string - email: - type: string - /settings/jellyfin/sync: - get: - summary: Get status of full Jellyfin library sync - description: Returns sync progress in a JSON array. - tags: - - settings - responses: - '200': - description: Status of Jellyfin sync - content: - application/json: - schema: - type: object - properties: - running: - type: boolean - example: false - progress: - type: integer - example: 0 - total: - type: integer - example: 100 - currentLibrary: - $ref: '#/components/schemas/JellyfinLibrary' - libraries: - type: array - items: - $ref: '#/components/schemas/JellyfinLibrary' - post: - summary: Start full Jellyfin library sync - description: Runs a full Jellyfin library sync and returns the progress in a JSON array. - tags: - - settings - requestBody: - content: - application/json: - schema: - type: object - properties: - cancel: - type: boolean - example: false - start: - type: boolean - example: false - responses: - '200': - description: Status of Jellyfin sync - content: - application/json: - schema: - type: object - properties: - running: - type: boolean - example: false - progress: - type: integer - example: 0 - total: - type: integer - example: 100 - currentLibrary: - $ref: '#/components/schemas/JellyfinLibrary' - libraries: - type: array - items: - $ref: '#/components/schemas/JellyfinLibrary' - /settings/plex: - get: - summary: Get Plex settings - description: Retrieves current Plex settings. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/PlexSettings' - post: - summary: Update Plex settings - description: Updates Plex settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PlexSettings' - responses: - '200': - description: 'Values were successfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/PlexSettings' - /settings/plex/library: - get: - summary: Get Plex libraries - description: Returns a list of Plex libraries in a JSON array. - tags: - - settings - parameters: - - in: query - name: sync - description: Syncs the current libraries with the current Plex server - schema: - type: string - nullable: true - - in: query - name: enable - explode: false - allowReserved: true - description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! - schema: - type: string - nullable: true - responses: - '200': - description: 'Plex libraries returned' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlexLibrary' - /settings/plex/sync: - get: - summary: Get status of full Plex library scan - description: Returns scan progress in a JSON array. - tags: - - settings - responses: - '200': - description: Status of Plex scan - content: - application/json: - schema: - type: object - properties: - running: - type: boolean - example: false - progress: - type: integer - example: 0 - total: - type: integer - example: 100 - currentLibrary: - $ref: '#/components/schemas/PlexLibrary' - libraries: - type: array - items: - $ref: '#/components/schemas/PlexLibrary' - post: - summary: Start full Plex library scan - description: Runs a full Plex library scan and returns the progress in a JSON array. - tags: - - settings - requestBody: - content: - application/json: - schema: - type: object - properties: - cancel: - type: boolean - example: false - start: - type: boolean - example: false - responses: - '200': - description: Status of Plex scan - content: - application/json: - schema: - type: object - properties: - running: - type: boolean - example: false - progress: - type: integer - example: 0 - total: - type: integer - example: 100 - currentLibrary: - $ref: '#/components/schemas/PlexLibrary' - libraries: - type: array - items: - $ref: '#/components/schemas/PlexLibrary' - /settings/plex/devices/servers: - get: - summary: Gets the user's available Plex servers - description: Returns a list of available Plex servers and their connectivity state - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/PlexDevice' - /settings/plex/users: - get: - summary: Get Plex users - description: | - Returns a list of Plex users in a JSON array. - - Requires the `MANAGE_USERS` permission. - tags: - - settings - - users - responses: - '200': - description: Plex users - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: string - title: - type: string - username: - type: string - email: - type: string - thumb: - type: string - /settings/metadatas: - get: - summary: Get Metadata settings - description: Retrieves current Metadata settings. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MetadataSettings' - put: - summary: Update Metadata settings - description: Updates Metadata settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MetadataSettings' - responses: - '200': - description: 'Values were successfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/MetadataSettings' - /settings/metadatas/test: - post: - summary: Test Provider configuration - description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - tmdb: - type: boolean - example: true - tvdb: - type: boolean - example: true - responses: - '200': - description: Succesfully connected to TVDB - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: 'Successfully connected to TVDB' - /settings/tautulli: - get: - summary: Get Tautulli settings - description: Retrieves current Tautulli settings. - tags: - - settings - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/TautulliSettings' - post: - summary: Update Tautulli settings - description: Updates Tautulli settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TautulliSettings' - responses: - '200': - description: 'Values were successfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/TautulliSettings' - /settings/radarr: - get: - summary: Get Radarr settings - description: Returns all Radarr settings in a JSON array. - tags: - - settings - responses: - '200': - description: 'Values were returned' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/RadarrSettings' - post: - summary: Create Radarr instance - description: Creates a new Radarr instance from the request body. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RadarrSettings' - responses: - '201': - description: 'New Radarr instance created' - content: - application/json: - schema: - $ref: '#/components/schemas/RadarrSettings' - /settings/radarr/test: - post: - summary: Test Radarr configuration - description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - hostname: - type: string - example: '127.0.0.1' - port: - type: integer - example: 7878 - apiKey: - type: string - example: yourapikey - useSsl: - type: boolean - example: false - baseUrl: - type: string - required: - - hostname - - port - - apiKey - - useSsl - responses: - '200': - description: Succesfully connected to Radarr instance - content: - application/json: - schema: - type: object - properties: - profiles: - type: array - items: - $ref: '#/components/schemas/ServiceProfile' - /settings/radarr/{radarrId}: - put: - summary: Update Radarr instance - description: Updates an existing Radarr instance with the provided values. - tags: - - settings - parameters: - - in: path - name: radarrId - required: true - schema: - type: integer - description: Radarr instance ID - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RadarrSettings' - responses: - '200': - description: 'Radarr instance updated' - content: - application/json: - schema: - $ref: '#/components/schemas/RadarrSettings' - delete: - summary: Delete Radarr instance - description: Deletes an existing Radarr instance based on the radarrId parameter. - tags: - - settings - parameters: - - in: path - name: radarrId - required: true - schema: - type: integer - description: Radarr instance ID - responses: - '200': - description: 'Radarr instance updated' - content: - application/json: - schema: - $ref: '#/components/schemas/RadarrSettings' - /settings/radarr/{radarrId}/profiles: - get: - summary: Get available Radarr profiles - description: Returns a list of profiles available on the Radarr server instance in a JSON array. - tags: - - settings - parameters: - - in: path - name: radarrId - required: true - schema: - type: integer - description: Radarr instance ID - responses: - '200': - description: Returned list of profiles - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ServiceProfile' - /settings/sonarr: - get: - summary: Get Sonarr settings - description: Returns all Sonarr settings in a JSON array. - tags: - - settings - responses: - '200': - description: 'Values were returned' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/SonarrSettings' - post: - summary: Create Sonarr instance - description: Creates a new Sonarr instance from the request body. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SonarrSettings' - responses: - '201': - description: 'New Sonarr instance created' - content: - application/json: - schema: - $ref: '#/components/schemas/SonarrSettings' - /settings/sonarr/test: - post: - summary: Test Sonarr configuration - description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - hostname: - type: string - example: '127.0.0.1' - port: - type: integer - example: 8989 - apiKey: - type: string - example: yourapikey - useSsl: - type: boolean - example: false - baseUrl: - type: string - required: - - hostname - - port - - apiKey - - useSsl - responses: - '200': - description: Succesfully connected to Sonarr instance - content: - application/json: - schema: - type: object - properties: - profiles: - type: array - items: - $ref: '#/components/schemas/ServiceProfile' - /settings/sonarr/{sonarrId}: - put: - summary: Update Sonarr instance - description: Updates an existing Sonarr instance with the provided values. - tags: - - settings - parameters: - - in: path - name: sonarrId - required: true - schema: - type: integer - description: Sonarr instance ID - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SonarrSettings' - responses: - '200': - description: 'Sonarr instance updated' - content: - application/json: - schema: - $ref: '#/components/schemas/SonarrSettings' - delete: - summary: Delete Sonarr instance - description: Deletes an existing Sonarr instance based on the sonarrId parameter. - tags: - - settings - parameters: - - in: path - name: sonarrId - required: true - schema: - type: integer - description: Sonarr instance ID - responses: - '200': - description: 'Sonarr instance updated' - content: - application/json: - schema: - $ref: '#/components/schemas/SonarrSettings' - /settings/public: - get: - summary: Get public settings - security: [] - description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. - tags: - - settings - responses: - '200': - description: Public settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/PublicSettings' - /settings/initialize: - post: - summary: Initialize application - description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. - tags: - - settings - responses: - '200': - description: Public settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/PublicSettings' - /settings/jobs: - get: - summary: Get scheduled jobs - description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. - tags: - - settings - responses: - '200': - description: Scheduled jobs returned - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Job' - /settings/jobs/{jobId}/run: - post: - summary: Invoke a specific job - description: Invokes a specific job to run. Will return the new job status in JSON format. - tags: - - settings - parameters: - - in: path - name: jobId - required: true - schema: - type: string - responses: - '200': - description: Invoked job returned - content: - application/json: - schema: - $ref: '#/components/schemas/Job' - /settings/jobs/{jobId}/cancel: - post: - summary: Cancel a specific job - description: Cancels a specific job. Will return the new job status in JSON format. - tags: - - settings - parameters: - - in: path - name: jobId - required: true - schema: - type: string - responses: - '200': - description: Canceled job returned - content: - application/json: - schema: - $ref: '#/components/schemas/Job' - /settings/jobs/{jobId}/schedule: - post: - summary: Modify job schedule - description: Re-registers the job with the schedule specified. Will return the job in JSON format. - tags: - - settings - parameters: - - in: path - name: jobId - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - schedule: - type: string - example: '0 */5 * * * *' - responses: - '200': - description: Rescheduled job - content: - application/json: - schema: - $ref: '#/components/schemas/Job' - /settings/cache: - get: - summary: Get a list of active caches - description: Retrieves a list of all active caches and their current stats. - tags: - - settings - responses: - '200': - description: Caches returned - content: - application/json: - schema: - type: object - properties: - imageCache: - type: object - properties: - tmdb: - type: object - properties: - size: - type: integer - example: 123456 - imageCount: - type: integer - example: 123 - avatar: - type: object - properties: - size: - type: integer - example: 123456 - imageCount: - type: integer - example: 123 - dnsCache: - type: object - properties: - stats: - type: object - properties: - size: - type: integer - example: 1 - maxSize: - type: integer - example: 500 - hits: - type: integer - example: 19 - misses: - type: integer - example: 1 - failures: - type: integer - example: 0 - ipv4Fallbacks: - type: integer - example: 0 - hitRate: - type: integer - example: 0.95 - entries: - type: array - additionalProperties: - type: object - properties: - addresses: - type: object - properties: - ipv4: - type: integer - example: 1 - ipv6: - type: integer - example: 1 - activeAddress: - type: string - example: 127.0.0.1 - family: - type: integer - example: 4 - age: - type: integer - example: 10 - ttl: - type: integer - example: 10 - networkErrors: - type: integer - example: 0 - hits: - type: integer - example: 1 - misses: - type: integer - example: 1 - apiCaches: - type: array - items: - type: object - properties: - id: - type: string - example: cache-id - name: - type: string - example: cache name - stats: - type: object - properties: - hits: - type: integer - misses: - type: integer - keys: - type: integer - ksize: - type: integer - vsize: - type: integer - /settings/cache/{cacheId}/flush: - post: - summary: Flush a specific cache - description: Flushes all data from the cache ID provided - tags: - - settings - parameters: - - in: path - name: cacheId - required: true - schema: - type: string - responses: - '204': - description: 'Flushed cache' - /settings/cache/dns/{dnsEntry}/flush: - post: - summary: Flush a specific DNS cache entry - description: Flushes a specific DNS cache entry - tags: - - settings - parameters: - - in: path - name: dnsEntry - required: true - schema: - type: string - responses: - '204': - description: 'Flushed dns cache' - /settings/logs: - get: - summary: Returns logs - description: Returns list of all log items and details - tags: - - settings - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 25 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: filter - schema: - type: string - nullable: true - enum: [debug, info, warn, error] - default: debug - - in: query - name: search - schema: - type: string - nullable: true - example: plex - responses: - '200': - description: Server log returned - content: - application/json: - schema: - type: array - items: - type: object - properties: - label: - type: string - example: server - level: - type: string - example: info - message: - type: string - example: Server ready on port 5055 - timestamp: - type: string - example: '2020-12-15T16:20:00.069Z' - /settings/notifications/email: - get: - summary: Get email notification settings - description: Returns current email notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned email settings - content: - application/json: - schema: - $ref: '#/components/schemas/NotificationEmailSettings' - post: - summary: Update email notification settings - description: Updates email notification settings with provided values - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NotificationEmailSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/NotificationEmailSettings' - /settings/notifications/email/test: - post: - summary: Test email settings - description: Sends a test notification to the email agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NotificationEmailSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/discord: - get: - summary: Get Discord notification settings - description: Returns current Discord notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned Discord settings - content: - application/json: - schema: - $ref: '#/components/schemas/DiscordSettings' - post: - summary: Update Discord notification settings - description: Updates Discord notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DiscordSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/DiscordSettings' - /settings/notifications/discord/test: - post: - summary: Test Discord settings - description: Sends a test notification to the Discord agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DiscordSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/pushbullet: - get: - summary: Get Pushbullet notification settings - description: Returns current Pushbullet notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned Pushbullet settings - content: - application/json: - schema: - $ref: '#/components/schemas/PushbulletSettings' - post: - summary: Update Pushbullet notification settings - description: Update Pushbullet notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PushbulletSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/PushbulletSettings' - /settings/notifications/pushbullet/test: - post: - summary: Test Pushbullet settings - description: Sends a test notification to the Pushbullet agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PushbulletSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/pushover: - get: - summary: Get Pushover notification settings - description: Returns current Pushover notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned Pushover settings - content: - application/json: - schema: - $ref: '#/components/schemas/PushoverSettings' - post: - summary: Update Pushover notification settings - description: Update Pushover notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PushoverSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/PushoverSettings' - /settings/notifications/pushover/test: - post: - summary: Test Pushover settings - description: Sends a test notification to the Pushover agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PushoverSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/pushover/sounds: - get: - summary: Get Pushover sounds - description: Returns valid Pushover sound options in a JSON array. - tags: - - settings - parameters: - - in: query - name: token - required: true - schema: - type: string - nullable: false - responses: - '200': - description: Returned Pushover settings - content: - application/json: - schema: - type: array - items: - type: object - properties: - name: - type: string - description: - type: string - /settings/notifications/gotify: - get: - summary: Get Gotify notification settings - description: Returns current Gotify notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned Gotify settings - content: - application/json: - schema: - $ref: '#/components/schemas/GotifySettings' - post: - summary: Update Gotify notification settings - description: Update Gotify notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GotifySettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/GotifySettings' - /settings/notifications/gotify/test: - post: - summary: Test Gotify settings - description: Sends a test notification to the Gotify agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GotifySettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/ntfy: - get: - summary: Get ntfy.sh notification settings - description: Returns current ntfy.sh notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned ntfy.sh settings - content: - application/json: - schema: - $ref: '#/components/schemas/NtfySettings' - post: - summary: Update ntfy.sh notification settings - description: Update ntfy.sh notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NtfySettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/NtfySettings' - /settings/notifications/ntfy/test: - post: - summary: Test ntfy.sh settings - description: Sends a test notification to the ntfy.sh agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NtfySettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/slack: - get: - summary: Get Slack notification settings - description: Returns current Slack notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned slack settings - content: - application/json: - schema: - $ref: '#/components/schemas/SlackSettings' - post: - summary: Update Slack notification settings - description: Updates Slack notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SlackSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/SlackSettings' - /settings/notifications/slack/test: - post: - summary: Test Slack settings - description: Sends a test notification to the Slack agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SlackSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/telegram: - get: - summary: Get Telegram notification settings - description: Returns current Telegram notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned Telegram settings - content: - application/json: - schema: - $ref: '#/components/schemas/TelegramSettings' - post: - summary: Update Telegram notification settings - description: Update Telegram notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TelegramSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/TelegramSettings' - /settings/notifications/telegram/test: - post: - summary: Test Telegram settings - description: Sends a test notification to the Telegram agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TelegramSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/webpush: - get: - summary: Get Web Push notification settings - description: Returns current Web Push notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned web push settings - content: - application/json: - schema: - $ref: '#/components/schemas/WebPushSettings' - post: - summary: Update Web Push notification settings - description: Updates Web Push notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/WebPushSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/WebPushSettings' - /settings/notifications/webpush/test: - post: - summary: Test Web Push settings - description: Sends a test notification to the Web Push agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/WebPushSettings' - responses: - '204': - description: Test notification attempted - /settings/notifications/webhook: - get: - summary: Get webhook notification settings - description: Returns current webhook notification settings in a JSON object. - tags: - - settings - responses: - '200': - description: Returned webhook settings - content: - application/json: - schema: - $ref: '#/components/schemas/WebhookSettings' - post: - summary: Update webhook notification settings - description: Updates webhook notification settings with the provided values. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/WebhookSettings' - responses: - '200': - description: 'Values were sucessfully updated' - content: - application/json: - schema: - $ref: '#/components/schemas/WebhookSettings' - /settings/notifications/webhook/test: - post: - summary: Test webhook settings - description: Sends a test notification to the webhook agent. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/WebhookSettings' - responses: - '204': - description: Test notification attempted - /settings/discover: - get: - summary: Get all discover sliders - description: Returns all discovery sliders. Built-in and custom made. - tags: - - settings - responses: - '200': - description: Returned all discovery sliders - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/DiscoverSlider' - post: - summary: Batch update all sliders. - description: | - Batch update all sliders at once. Should also be used for creation. Will only update sliders provided - and will not delete any sliders not present in the request. If a slider is missing a required field, - it will be ignored. Requires the `ADMIN` permission. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/DiscoverSlider' - responses: - '200': - description: Returned all newly updated discovery sliders - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/DiscoverSlider' - /settings/discover/{sliderId}: - put: - summary: Update a single slider - description: | - Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. - tags: - - settings - parameters: - - in: path - name: sliderId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - title: - type: string - example: 'Slider Title' - type: - type: integer - example: 1 - data: - type: string - example: '1' - responses: - '200': - description: Returns newly added discovery slider - content: - application/json: - schema: - $ref: '#/components/schemas/DiscoverSlider' - delete: - summary: Delete slider by ID - description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. - tags: - - settings - parameters: - - in: path - name: sliderId - required: true - schema: - type: integer - responses: - '200': - description: Slider successfully deleted - content: - application/json: - schema: - $ref: '#/components/schemas/DiscoverSlider' - /settings/discover/add: - post: - summary: Add a new slider - description: | - Add a single slider and return the newly created slider. Requires the `ADMIN` permission. - tags: - - settings - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - title: - type: string - example: 'New Slider' - type: - type: integer - example: 1 - data: - type: string - example: '1' - responses: - '200': - description: Returns newly added discovery slider - content: - application/json: - schema: - $ref: '#/components/schemas/DiscoverSlider' - /settings/discover/reset: - get: - summary: Reset all discover sliders - description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. - tags: - - settings - responses: - '204': - description: All sliders reset to defaults - /settings/about: - get: - summary: Get server stats - description: Returns current server stats in a JSON object. - tags: - - settings - responses: - '200': - description: Returned about settings - content: - application/json: - schema: - type: object - properties: - version: - type: string - example: '1.0.0' - totalRequests: - type: integer - example: 100 - totalMediaItems: - type: integer - example: 100 - tz: - type: string - nullable: true - example: Asia/Tokyo - appDataPath: - type: string - example: /app/config - /auth/me: - get: - summary: Get logged-in user - description: Returns the currently logged-in user. - tags: - - auth - - users - responses: - '200': - description: Object containing the logged-in user in JSON - content: - application/json: - schema: - $ref: '#/components/schemas/User' - /auth/plex: - post: - summary: Sign in using a Plex token - description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. - security: [] - tags: - - auth - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/User' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - authToken: - type: string - required: - - authToken - /auth/jellyfin: - post: - summary: Sign in using a Jellyfin username and password - description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. - security: [] - tags: - - auth - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/User' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - username: - type: string - password: - type: string - hostname: - type: string - email: - type: string - serverType: - type: integer - required: - - username - - password - /auth/local: - post: - summary: Sign in using a local account - description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. - security: [] - tags: - - auth - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/User' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - password: - type: string - required: - - email - - password - /auth/logout: - post: - summary: Sign out and clear session cookie - description: Completely clear the session cookie and associated values, effectively signing the user out. - tags: - - auth - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: 'ok' - /auth/reset-password: - post: - summary: Send a reset password email - description: Sends a reset password email to the email if the user exists - security: [] - tags: - - users - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: 'ok' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - required: - - email - /auth/reset-password/{guid}: - post: - summary: Reset the password for a user - description: Resets the password for a user if the given guid is connected to a user - security: [] - tags: - - users - parameters: - - in: path - name: guid - required: true - schema: - type: string - example: '9afef5a7-ec89-4d5f-9397-261e96970b50' - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: 'ok' - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - password: - type: string - required: - - password - /user: - get: - summary: Get all users - description: Returns all users in a JSON object. - tags: - - users - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 20 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: sort - schema: - type: string - enum: [created, updated, requests, displayname] - default: created - - in: query - name: q - required: false - schema: - type: string - - in: query - name: includeIds - required: false - schema: - type: string - responses: - '200': - description: A JSON array of all users - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - $ref: '#/components/schemas/User' - post: - summary: Create new user - description: | - Creates a new user. Requires the `MANAGE_USERS` permission. - tags: - - users - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - example: 'hey@itsme.com' - username: - type: string - permissions: - type: integer - responses: - '201': - description: The created user - content: - application/json: - schema: - $ref: '#/components/schemas/User' - put: - summary: Update batch of users - description: | - Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request. - - Requires the `MANAGE_USERS` permission. - tags: - - users - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - ids: - type: array - items: - type: integer - permissions: - type: integer - responses: - '200': - description: Successfully updated user details - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' - /user/import-from-plex: - post: - summary: Import all users from Plex - description: | - Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. - - Requires the `MANAGE_USERS` permission. - tags: - - users - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - plexIds: - type: array - items: - type: string - responses: - '201': - description: A list of the newly created users - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' - /user/import-from-jellyfin: - post: - summary: Import all users from Jellyfin - description: | - Fetches and imports users from the Jellyfin server. - - Requires the `MANAGE_USERS` permission. - tags: - - users - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - jellyfinUserIds: - type: array - items: - type: string - responses: - '201': - description: A list of the newly created users - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/User' - /user/registerPushSubscription: - post: - summary: Register a web push /user/registerPushSubscription - description: Registers a web push subscription for the logged-in user - tags: - - users - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - endpoint: - type: string - auth: - type: string - p256dh: - type: string - userAgent: - type: string - required: - - endpoint - - auth - - p256dh - responses: - '204': - description: Successfully registered push subscription - /user/{userId}/pushSubscriptions: - get: - summary: Get all web push notification settings for a user - description: | - Returns all web push notification settings for a user in a JSON object. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User web push notification settings in JSON - content: - application/json: - schema: - type: object - properties: - endpoint: - type: string - p256dh: - type: string - auth: - type: string - userAgent: - type: string - /user/{userId}/pushSubscription/{endpoint}: - get: - summary: Get web push notification settings for a user - description: | - Returns web push notification settings for a user in a JSON object. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - - in: path - name: endpoint - required: true - schema: - type: string - responses: - '200': - description: User web push notification settings in JSON - content: - application/json: - schema: - type: object - properties: - endpoint: - type: string - p256dh: - type: string - auth: - type: string - userAgent: - type: string - delete: - summary: Delete user push subscription by key - description: Deletes the user push subscription with the provided key. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - - in: path - name: endpoint - required: true - schema: - type: string - responses: - '204': - description: Successfully removed user push subscription - /user/{userId}: - get: - summary: Get user by ID - description: | - Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: Users details in JSON - content: - application/json: - schema: - $ref: '#/components/schemas/User' - put: - summary: Update a user by user ID - description: | - Update a user with the provided values. You cannot update a user's Plex token through this request. - - Requires the `MANAGE_USERS` permission. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/User' - responses: - '200': - description: Successfully updated user details - content: - application/json: - schema: - $ref: '#/components/schemas/User' - delete: - summary: Delete user by ID - description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User successfully deleted - content: - application/json: - schema: - $ref: '#/components/schemas/User' - /user/{userId}/requests: - get: - summary: Get requests for a specific user - description: | - Retrieves a user's requests in a JSON object. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - - in: query - name: take - schema: - type: integer - nullable: true - example: 20 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - responses: - '200': - description: User's requests returned - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - $ref: '#/components/schemas/MediaRequest' - /user/{userId}/quota: - get: - summary: Get quotas for a specific user - description: | - Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User quota details in JSON - content: - application/json: - schema: - type: object - properties: - movie: - type: object - properties: - days: - type: integer - example: 7 - limit: - type: integer - example: 10 - used: - type: integer - example: 6 - remaining: - type: integer - example: 4 - restricted: - type: boolean - example: false - tv: - type: object - properties: - days: - type: integer - example: 7 - limit: - type: integer - example: 10 - used: - type: integer - example: 6 - remaining: - type: integer - example: 4 - restricted: - type: boolean - example: false - /blacklist: - get: - summary: Returns blacklisted items - description: Returns list of all blacklisted media - tags: - - settings - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 25 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: search - schema: - type: string - nullable: true - example: dune - - in: query - name: filter - schema: - type: string - enum: [all, manual, blacklistedTags] - default: manual - responses: - '200': - description: Blacklisted items returned - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - type: object - properties: - user: - $ref: '#/components/schemas/User' - createdAt: - type: string - example: 2024-04-21T01:55:44.000Z - id: - type: integer - example: 1 - mediaType: - type: string - example: movie - title: - type: string - example: Dune - tmdbid: - type: integer - example: 438631 - post: - summary: Add media to blacklist - tags: - - blacklist - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Blacklist' - responses: - '201': - description: Item succesfully blacklisted - '412': - description: Item has already been blacklisted - /blacklist/{tmdbId}: - get: - summary: Get media from blacklist - tags: - - blacklist - parameters: - - in: path - name: tmdbId - description: tmdbId ID - required: true - example: '1' - schema: - type: string - responses: - '200': - description: Blacklist details in JSON - delete: - summary: Remove media from blacklist - tags: - - blacklist - parameters: - - in: path - name: tmdbId - description: tmdbId ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed media item - /watchlist: - post: - summary: Add media to watchlist - tags: - - watchlist - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/Watchlist' - responses: - '200': - description: Watchlist data returned - content: - application/json: - schema: - $ref: '#/components/schemas/Watchlist' - /watchlist/{tmdbId}: - delete: - summary: Delete watchlist item - description: Removes a watchlist item. - tags: - - watchlist - parameters: - - in: path - name: tmdbId - description: tmdbId ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed watchlist item - /user/{userId}/watchlist: - get: - summary: Get the Plex watchlist for a specific user - description: | - Retrieves a user's Plex Watchlist in a JSON object. - tags: - - users - - watchlist - parameters: - - in: path - name: userId - required: true - schema: - type: integer - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - responses: - '200': - description: Watchlist data returned - content: - application/json: - schema: - type: object - properties: - page: - type: integer - totalPages: - type: integer - totalResults: - type: integer - results: - type: array - items: - type: object - properties: - tmdbid: - type: integer - example: 1 - ratingKey: - type: string - type: - type: string - title: - type: string - /user/{userId}/settings/main: - get: - summary: Get general settings for a user - description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User general settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettings' - post: - summary: Update general settings for a user - description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettings' - responses: - '200': - description: Updated user general settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettings' - /user/{userId}/settings/password: - get: - summary: Get password page informatiom - description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User password page information returned - content: - application/json: - schema: - type: object - properties: - hasPassword: - type: boolean - example: true - post: - summary: Update password for a user - description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - currentPassword: - type: string - nullable: true - newPassword: - type: string - required: - - newPassword - responses: - '204': - description: User password updated - /user/{userId}/settings/linked-accounts/plex: - post: - summary: Link the provided Plex account to the current user - description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - authToken: - type: string - required: - - authToken - responses: - '204': - description: Linking account succeeded - '403': - description: Invalid credentials - '422': - description: Account already linked to a user - delete: - summary: Remove the linked Plex account for a user - description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '204': - description: Unlinking account succeeded - '400': - description: Unlink request invalid - '404': - description: User does not exist - /user/{userId}/settings/linked-accounts/jellyfin: - post: - summary: Link the provided Jellyfin account to the current user - description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - username: - type: string - example: 'Mr User' - password: - type: string - example: 'supersecret' - responses: - '204': - description: Linking account succeeded - '403': - description: Invalid credentials - '422': - description: Account already linked to a user - delete: - summary: Remove the linked Jellyfin account for a user - description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '204': - description: Unlinking account succeeded - '400': - description: Unlink request invalid - '404': - description: User does not exist - /user/{userId}/settings/notifications: - get: - summary: Get notification settings for a user - description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User notification settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettingsNotifications' - post: - summary: Update notification settings for a user - description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettingsNotifications' - responses: - '200': - description: Updated user notification settings returned - content: - application/json: - schema: - $ref: '#/components/schemas/UserSettingsNotifications' - /user/{userId}/settings/permissions: - get: - summary: Get permission settings for a user - description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: User permission settings returned - content: - application/json: - schema: - type: object - properties: - permissions: - type: integer - example: 2 - post: - summary: Update permission settings for a user - description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - permissions: - type: integer - required: - - permissions - responses: - '200': - description: Updated user general settings returned - content: - application/json: - schema: - type: object - properties: - permissions: - type: integer - example: 2 - /user/{userId}/watch_data: - get: - summary: Get watch data - description: | - Returns play count, play duration, and recently watched media. - - Requires the `ADMIN` permission to fetch results for other users. - tags: - - users - parameters: - - in: path - name: userId - required: true - schema: - type: integer - responses: - '200': - description: Users - content: - application/json: - schema: - type: object - properties: - recentlyWatched: - type: array - items: - $ref: '#/components/schemas/MediaInfo' - playCount: - type: integer - /search: - get: - summary: Search for movies, TV shows, or people - description: Returns a list of movies, TV shows, or people a JSON object. - tags: - - search - parameters: - - in: query - name: query - required: true - schema: - type: string - example: 'Mulan' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - anyOf: - - $ref: '#/components/schemas/MovieResult' - - $ref: '#/components/schemas/TvResult' - - $ref: '#/components/schemas/PersonResult' - /search/keyword: - get: - summary: Search for keywords - description: Returns a list of TMDB keywords matching the search query - tags: - - search - parameters: - - in: query - name: query - required: true - schema: - type: string - example: 'christmas' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/Keyword' - /search/company: - get: - summary: Search for companies - description: Returns a list of TMDB companies matching the search query. (Will not return origin country) - tags: - - search - parameters: - - in: query - name: query - required: true - schema: - type: string - example: 'Disney' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/Company' - /discover/movies: - get: - summary: Discover movies - description: Returns a list of movies in a JSON object. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - - in: query - name: genre - schema: - type: string - example: 18 - - in: query - name: studio - schema: - type: integer - example: 1 - - in: query - name: keywords - schema: - type: string - example: 1,2 - - in: query - name: excludeKeywords - schema: - type: string - example: 3,4 - description: Comma-separated list of keyword IDs to exclude from results - - in: query - name: sortBy - schema: - type: string - example: popularity.desc - - in: query - name: primaryReleaseDateGte - schema: - type: string - example: 2022-01-01 - - in: query - name: primaryReleaseDateLte - schema: - type: string - example: 2023-01-01 - - in: query - name: withRuntimeGte - schema: - type: integer - example: 60 - - in: query - name: withRuntimeLte - schema: - type: integer - example: 120 - - in: query - name: voteAverageGte - schema: - type: integer - example: 7 - - in: query - name: voteAverageLte - schema: - type: integer - example: 10 - - in: query - name: voteCountGte - schema: - type: integer - example: 7 - - in: query - name: voteCountLte - schema: - type: integer - example: 10 - - in: query - name: watchRegion - schema: - type: string - example: US - - in: query - name: watchProviders - schema: - type: string - example: 8|9 - - in: query - name: certification - schema: - type: string - example: PG-13 - description: Exact certification to filter by (used when certificationMode is 'exact') - - in: query - name: certificationGte - schema: - type: string - example: G - description: Minimum certification to filter by (used when certificationMode is 'range') - - in: query - name: certificationLte - schema: - type: string - example: PG-13 - description: Maximum certification to filter by (used when certificationMode is 'range') - - in: query - name: certificationCountry - schema: - type: string - example: US - description: Country code for the certification system (e.g., US, GB, CA) - - in: query - name: certificationMode - schema: - type: string - enum: [exact, range] - example: exact - description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/movies/genre/{genreId}: - get: - summary: Discover movies by genre - description: Returns a list of movies based on the provided genre ID in a JSON object. - tags: - - search - parameters: - - in: path - name: genreId - required: true - schema: - type: string - example: '1' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - genre: - $ref: '#/components/schemas/Genre' - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/movies/language/{language}: - get: - summary: Discover movies by original language - description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. - tags: - - search - parameters: - - in: path - name: language - required: true - schema: - type: string - example: en - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - language: - $ref: '#/components/schemas/SpokenLanguage' - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/movies/studio/{studioId}: - get: - summary: Discover movies by studio - description: Returns a list of movies based on the provided studio ID in a JSON object. - tags: - - search - parameters: - - in: path - name: studioId - required: true - schema: - type: string - example: '1' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - studio: - $ref: '#/components/schemas/ProductionCompany' - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/movies/upcoming: - get: - summary: Upcoming movies - description: Returns a list of movies in a JSON object. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/tv: - get: - summary: Discover TV shows - description: Returns a list of TV shows in a JSON object. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - - in: query - name: genre - schema: - type: string - example: 18 - - in: query - name: network - schema: - type: integer - example: 1 - - in: query - name: keywords - schema: - type: string - example: 1,2 - - in: query - name: excludeKeywords - schema: - type: string - example: 3,4 - description: Comma-separated list of keyword IDs to exclude from results - - in: query - name: sortBy - schema: - type: string - example: popularity.desc - - in: query - name: firstAirDateGte - schema: - type: string - example: 2022-01-01 - - in: query - name: firstAirDateLte - schema: - type: string - example: 2023-01-01 - - in: query - name: withRuntimeGte - schema: - type: integer - example: 60 - - in: query - name: withRuntimeLte - schema: - type: integer - example: 120 - - in: query - name: voteAverageGte - schema: - type: integer - example: 7 - - in: query - name: voteAverageLte - schema: - type: integer - example: 10 - - in: query - name: voteCountGte - schema: - type: integer - example: 7 - - in: query - name: voteCountLte - schema: - type: integer - example: 10 - - in: query - name: watchRegion - schema: - type: string - example: US - - in: query - name: watchProviders - schema: - type: string - example: 8|9 - - in: query - name: status - schema: - type: string - example: 3|4 - - in: query - name: certification - schema: - type: string - example: TV-14 - description: Exact certification to filter by (used when certificationMode is 'exact') - - in: query - name: certificationGte - schema: - type: string - example: TV-PG - description: Minimum certification to filter by (used when certificationMode is 'range') - - in: query - name: certificationLte - schema: - type: string - example: TV-MA - description: Maximum certification to filter by (used when certificationMode is 'range') - - in: query - name: certificationCountry - schema: - type: string - example: US - description: Country code for the certification system (e.g., US, GB, CA) - - in: query - name: certificationMode - schema: - type: string - enum: [exact, range] - example: exact - description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /discover/tv/language/{language}: - get: - summary: Discover TV shows by original language - description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. - tags: - - search - parameters: - - in: path - name: language - required: true - schema: - type: string - example: en - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - language: - $ref: '#/components/schemas/SpokenLanguage' - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /discover/tv/genre/{genreId}: - get: - summary: Discover TV shows by genre - description: Returns a list of TV shows based on the provided genre ID in a JSON object. - tags: - - search - parameters: - - in: path - name: genreId - required: true - schema: - type: string - example: '1' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - genre: - $ref: '#/components/schemas/Genre' - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /discover/tv/network/{networkId}: - get: - summary: Discover TV shows by network - description: Returns a list of TV shows based on the provided network ID in a JSON object. - tags: - - search - parameters: - - in: path - name: networkId - required: true - schema: - type: string - example: '1' - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - network: - $ref: '#/components/schemas/Network' - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /discover/tv/upcoming: - get: - summary: Discover Upcoming TV shows - description: Returns a list of upcoming TV shows in a JSON object. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /discover/trending: - get: - summary: Trending movies and TV - description: Returns a list of movies and TV shows in a JSON object. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - anyOf: - - $ref: '#/components/schemas/MovieResult' - - $ref: '#/components/schemas/TvResult' - - $ref: '#/components/schemas/PersonResult' - /discover/keyword/{keywordId}/movies: - get: - summary: Get movies from keyword - description: Returns list of movies based on the provided keyword ID a JSON object. - tags: - - search - parameters: - - in: path - name: keywordId - required: true - schema: - type: integer - example: 207317 - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: List of movies - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /discover/genreslider/movie: - get: - summary: Get genre slider data for movies - description: Returns a list of genres with backdrops attached - tags: - - search - parameters: - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Genre slider data returned - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: integer - example: 1 - backdrops: - type: array - items: - type: string - name: - type: string - example: Genre Name - /discover/genreslider/tv: - get: - summary: Get genre slider data for TV series - description: Returns a list of genres with backdrops attached - tags: - - search - parameters: - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Genre slider data returned - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: integer - example: 1 - backdrops: - type: array - items: - type: string - name: - type: string - example: Genre Name - /discover/watchlist: - get: - summary: Get the Plex watchlist. - tags: - - search - parameters: - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - responses: - '200': - description: Watchlist data returned - content: - application/json: - schema: - type: object - properties: - page: - type: integer - totalPages: - type: integer - totalResults: - type: integer - results: - type: array - items: - type: object - properties: - tmdbid: - type: integer - example: 1 - ratingKey: - type: string - type: - type: string - title: - type: string - /request: - get: - summary: Get all requests - description: | - Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. - - If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. - tags: - - request - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 20 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: filter - schema: - type: string - nullable: true - enum: - [ - all, - approved, - available, - pending, - processing, - unavailable, - failed, - deleted, - completed, - ] - - in: query - name: sort - schema: - type: string - enum: [added, modified] - default: added - - in: query - name: sortDirection - schema: - type: string - enum: [asc, desc] - nullable: true - default: desc - - in: query - name: requestedBy - schema: - type: integer - nullable: true - example: 1 - - in: query - name: mediaType - schema: - type: string - enum: [movie, tv, all] - nullable: true - default: all - responses: - '200': - description: Requests returned - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - $ref: '#/components/schemas/MediaRequest' - post: - summary: Create new request - description: | - Creates a new request with the provided media ID and type. The `REQUEST` permission is required. - - If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. - tags: - - request - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - mediaType: - type: string - enum: [movie, tv] - example: movie - mediaId: - type: integer - example: 123 - tvdbid: - type: integer - example: 123 - seasons: - type: array - items: - type: integer - minimum: 0 - nullable: true - is4k: - type: boolean - example: false - serverId: - type: integer - profileId: - type: integer - rootFolder: - type: string - nullable: true - languageProfileid: - type: integer - userid: - type: integer - nullable: true - tags: - type: array - items: - type: integer - required: - - mediaType - - mediaId - responses: - '201': - description: Succesfully created the request - content: - application/json: - schema: - $ref: '#/components/schemas/MediaRequest' - /request/count: - get: - summary: Gets request counts - description: | - Returns the number of requests by status including pending, approved, available, and completed requests. - tags: - - request - responses: - '200': - description: Request counts returned - content: - application/json: - schema: - type: object - properties: - total: - type: integer - movie: - type: integer - tv: - type: integer - pending: - type: integer - approved: - type: integer - declined: - type: integer - processing: - type: integer - available: - type: integer - completed: - type: integer - /request/{requestId}: - get: - summary: Get MediaRequest - description: Returns a specific MediaRequest in a JSON object. - tags: - - request - parameters: - - in: path - name: requestId - description: Request ID - required: true - example: '1' - schema: - type: string - responses: - '200': - description: Succesfully returns request - content: - application/json: - schema: - $ref: '#/components/schemas/MediaRequest' - put: - summary: Update MediaRequest - description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. - tags: - - request - parameters: - - in: path - name: requestId - description: Request ID - required: true - example: '1' - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - mediaType: - type: string - enum: [movie, tv] - seasons: - type: array - items: - type: integer - minimum: 0 - is4k: - type: boolean - example: false - serverId: - type: integer - profileId: - type: integer - rootFolder: - type: string - languageProfileid: - type: integer - userid: - type: integer - nullable: true - required: - - mediaType - responses: - '200': - description: Succesfully updated request - content: - application/json: - schema: - $ref: '#/components/schemas/MediaRequest' - delete: - summary: Delete request - description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. - tags: - - request - parameters: - - in: path - name: requestId - description: Request ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed request - /request/{requestId}/retry: - post: - summary: Retry failed request - description: | - Retries a request by resending requests to Sonarr or Radarr. - - Requires the `MANAGE_REQUESTS` permission or `ADMIN`. - tags: - - request - parameters: - - in: path - name: requestId - description: Request ID - required: true - schema: - type: string - example: '1' - responses: - '200': - description: Retry triggered - content: - application/json: - schema: - $ref: '#/components/schemas/MediaRequest' - /request/{requestId}/{status}: - post: - summary: Update a request's status - description: | - Updates a request's status to approved or declined. Also returns the request in a JSON object. - - Requires the `MANAGE_REQUESTS` permission or `ADMIN`. - tags: - - request - parameters: - - in: path - name: requestId - description: Request ID - required: true - schema: - type: string - example: '1' - - in: path - name: status - description: New status - required: true - schema: - type: string - enum: [approve, decline] - responses: - '200': - description: Request status changed - content: - application/json: - schema: - $ref: '#/components/schemas/MediaRequest' - /movie/{movieId}: - get: - summary: Get movie details - description: Returns full movie details in a JSON object. - tags: - - movies - parameters: - - in: path - name: movieId - required: true - schema: - type: integer - example: 337401 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Movie details - content: - application/json: - schema: - $ref: '#/components/schemas/MovieDetails' - /movie/{movieId}/recommendations: - get: - summary: Get recommended movies - description: Returns list of recommended movies based on provided movie ID in a JSON object. - tags: - - movies - parameters: - - in: path - name: movieId - required: true - schema: - type: integer - example: 337401 - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: List of movies - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /movie/{movieId}/similar: - get: - summary: Get similar movies - description: Returns list of similar movies based on the provided movieId in a JSON object. - tags: - - movies - parameters: - - in: path - name: movieId - required: true - schema: - type: integer - example: 337401 - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: List of movies - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/MovieResult' - /movie/{movieId}/ratings: - get: - summary: Get movie ratings - description: Returns ratings based on the provided movieId in a JSON object. - tags: - - movies - parameters: - - in: path - name: movieId - required: true - schema: - type: integer - example: 337401 - responses: - '200': - description: Ratings returned - content: - application/json: - schema: - type: object - properties: - title: - type: string - example: Mulan - year: - type: integer - example: 2020 - url: - type: string - example: 'http://www.rottentomatoes.com/m/mulan_2020/' - criticsScore: - type: integer - example: 85 - criticsRating: - type: string - example: 'Certified Fresh' - audienceScore: - type: integer - example: 65 - audienceRating: - type: string - example: 'Upright' - /movie/{movieId}/ratingscombined: - get: - summary: Get RT and IMDB movie ratings combined - description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. - tags: - - movies - parameters: - - in: path - name: movieId - required: true - schema: - type: integer - example: 337401 - responses: - '200': - description: Ratings returned - content: - application/json: - schema: - type: object - properties: - rt: - type: object - properties: - title: - type: string - example: Mulan - year: - type: integer - example: 2020 - url: - type: string - example: 'http://www.rottentomatoes.com/m/mulan_2020/' - criticsScore: - type: integer - example: 85 - criticsRating: - type: string - example: 'Certified Fresh' - audienceScore: - type: integer - example: 65 - audienceRating: - type: string - example: 'Upright' - imdb: - type: object - properties: - title: - type: string - example: I am Legend - url: - type: string - example: 'https://www.imdb.com/title/tt0480249' - criticsScore: - type: number - example: 8.8 - criticsScoreCount: - type: integer - format: int64 - example: 2500000 - /tv/{tvId}: - get: - summary: Get TV details - description: Returns full TV details in a JSON object. - tags: - - tv - parameters: - - in: path - name: tvId - required: true - schema: - type: integer - example: 76479 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: TV details - content: - application/json: - schema: - $ref: '#/components/schemas/TvDetails' - /tv/{tvId}/season/{seasonNumber}: - get: - summary: Get season details and episode list - description: Returns season details with a list of episodes in a JSON object. - tags: - - tv - parameters: - - in: path - name: tvId - required: true - schema: - type: integer - example: 76479 - - in: path - name: seasonNumber - required: true - schema: - type: integer - example: 123456 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: TV details - content: - application/json: - schema: - $ref: '#/components/schemas/SeasonDetails' - /tv/{tvId}/recommendations: - get: - summary: Get recommended TV series - description: Returns list of recommended TV series based on the provided tvId in a JSON object. - tags: - - tv - parameters: - - in: path - name: tvId - required: true - schema: - type: integer - example: 76479 - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: List of TV series - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /tv/{tvId}/similar: - get: - summary: Get similar TV series - description: Returns list of similar TV series based on the provided tvId in a JSON object. - tags: - - tv - parameters: - - in: path - name: tvId - required: true - schema: - type: integer - example: 76479 - - in: query - name: page - schema: - type: integer - example: 1 - default: 1 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: List of TV series - content: - application/json: - schema: - type: object - properties: - page: - type: integer - example: 1 - totalPages: - type: integer - example: 20 - totalResults: - type: integer - example: 200 - results: - type: array - items: - $ref: '#/components/schemas/TvResult' - /tv/{tvId}/ratings: - get: - summary: Get TV ratings - description: Returns ratings based on provided tvId in a JSON object. - tags: - - tv - parameters: - - in: path - name: tvId - required: true - schema: - type: integer - example: 76479 - responses: - '200': - description: Ratings returned - content: - application/json: - schema: - type: object - properties: - title: - type: string - example: The Boys - year: - type: integer - example: 2019 - url: - type: string - example: 'http://www.rottentomatoes.com/m/mulan_2020/' - criticsScore: - type: integer - example: 85 - criticsRating: - type: string - example: 'Certified Fresh' - audienceScore: - type: integer - example: 65 - audienceRating: - type: string - example: 'Upright' - /person/{personId}: - get: - summary: Get person details - description: Returns person details based on provided personId in a JSON object. - tags: - - person - parameters: - - in: path - name: personId - required: true - schema: - type: integer - example: 287 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Returned person - content: - application/json: - schema: - $ref: '#/components/schemas/PersonDetails' - /person/{personId}/combined_credits: - get: - summary: Get combined credits - description: Returns the person's combined credits based on the provided personId in a JSON object. - tags: - - person - parameters: - - in: path - name: personId - required: true - schema: - type: integer - example: 287 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Returned combined credits - content: - application/json: - schema: - type: object - properties: - cast: - type: array - items: - $ref: '#/components/schemas/CreditCast' - crew: - type: array - items: - $ref: '#/components/schemas/CreditCrew' - id: - type: integer - /media: - get: - summary: Get media - description: Returns all media (can be filtered and limited) in a JSON object. - tags: - - media - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 20 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: filter - schema: - type: string - nullable: true - enum: - [ - all, - available, - partial, - allavailable, - processing, - pending, - deleted, - ] - - in: query - name: sort - schema: - type: string - enum: [added, modified, mediaAdded] - default: added - responses: - '200': - description: Returned media - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - $ref: '#/components/schemas/MediaInfo' - /media/{mediaId}: - delete: - summary: Delete media item - description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. - tags: - - media - parameters: - - in: path - name: mediaId - description: Media ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed media item - /media/{mediaId}/file: - delete: - summary: Delete media file - description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. - tags: - - media - parameters: - - in: path - name: mediaId - description: Media ID - required: true - example: '1' - schema: - type: string - - in: query - name: is4k - description: Whether to remove from 4K service instance (true) or regular service instance (false) - required: false - example: false - schema: - type: boolean - responses: - '204': - description: Successfully removed media item - /media/{mediaId}/{status}: - post: - summary: Update media status - description: Updates a media item's status and returns the media in JSON format - tags: - - media - parameters: - - in: path - name: mediaId - description: Media ID - required: true - example: '1' - schema: - type: string - - in: path - name: status - description: New status - required: true - example: available - schema: - type: string - enum: [available, partial, processing, pending, unknown, deleted] - requestBody: - content: - application/json: - schema: - type: object - properties: - is4k: - type: boolean - example: false - description: | - When true, updates the 4K status field (status4k). - When false or not provided, updates the regular status field (status). - This applies to all status values (available, partial, processing, pending, unknown). - responses: - '200': - description: Returned media - content: - application/json: - schema: - $ref: '#/components/schemas/MediaInfo' - /media/{mediaId}/watch_data: - get: - summary: Get watch data - description: | - Returns play count, play duration, and users who have watched the media. - - Requires the `ADMIN` permission. - tags: - - media - parameters: - - in: path - name: mediaId - description: Media ID - required: true - example: '1' - schema: - type: string - responses: - '200': - description: Users - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - playCount7Days: - type: integer - playCount30Days: - type: integer - playCount: - type: integer - users: - type: array - items: - $ref: '#/components/schemas/User' - data4k: - type: object - properties: - playCount7Days: - type: integer - playCount30Days: - type: integer - playCount: - type: integer - users: - type: array - items: - $ref: '#/components/schemas/User' - /collection/{collectionId}: - get: - summary: Get collection details - description: Returns full collection details in a JSON object. - tags: - - collection - parameters: - - in: path - name: collectionId - required: true - schema: - type: integer - example: 537982 - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Collection details - content: - application/json: - schema: - $ref: '#/components/schemas/Collection' - /service/radarr: - get: - summary: Get non-sensitive Radarr server list - description: Returns a list of Radarr server IDs and names in a JSON object. - tags: - - service - responses: - '200': - description: Request successful - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/RadarrSettings' - /service/radarr/{radarrId}: - get: - summary: Get Radarr server quality profiles and root folders - description: Returns a Radarr server's quality profile and root folder details in a JSON object. - tags: - - service - parameters: - - in: path - name: radarrId - required: true - schema: - type: integer - example: 0 - responses: - '200': - description: Request successful - content: - application/json: - schema: - type: object - properties: - server: - $ref: '#/components/schemas/RadarrSettings' - profiles: - 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 - description: Returns a list of Sonarr server IDs and names in a JSON object. - tags: - - service - responses: - '200': - description: Request successful - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/SonarrSettings' - /service/sonarr/{sonarrId}: - get: - summary: Get Sonarr server quality profiles and root folders - description: Returns a Sonarr server's quality profile and root folder details in a JSON object. - tags: - - service - parameters: - - in: path - name: sonarrId - required: true - schema: - type: integer - example: 0 - responses: - '200': - description: Request successful - content: - application/json: - schema: - type: object - properties: - server: - $ref: '#/components/schemas/SonarrSettings' - profiles: - 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 - description: Returns a list of series returned by searching for the name in Sonarr. - tags: - - service - parameters: - - in: path - name: tmdbId - required: true - schema: - type: integer - example: 0 - responses: - '200': - description: Request successful - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/SonarrSeries' - /regions: - get: - summary: Regions supported by TMDB - description: Returns a list of regions in a JSON object. - tags: - - tmdb - responses: - '200': - description: Results - content: - application/json: - schema: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - example: US - english_name: - type: string - example: United States of America - /languages: - get: - summary: Languages supported by TMDB - description: Returns a list of languages in a JSON object. - tags: - - tmdb - responses: - '200': - description: Results - content: - application/json: - schema: - type: array - items: - type: object - properties: - iso_639_1: - type: string - example: en - english_name: - type: string - example: English - name: - type: string - example: English - /studio/{studioId}: - get: - summary: Get movie studio details - description: Returns movie studio details in a JSON object. - tags: - - tmdb - parameters: - - in: path - name: studioId - required: true - schema: - type: integer - example: 2 - responses: - '200': - description: Movie studio details - content: - application/json: - schema: - $ref: '#/components/schemas/ProductionCompany' - /network/{networkId}: - get: - summary: Get TV network details - description: Returns TV network details in a JSON object. - tags: - - tmdb - parameters: - - in: path - name: networkId - required: true - schema: - type: integer - example: 1 - responses: - '200': - description: TV network details - content: - application/json: - schema: - $ref: '#/components/schemas/ProductionCompany' - /genres/movie: - get: - summary: Get list of official TMDB movie genres - description: Returns a list of genres in a JSON array. - tags: - - tmdb - parameters: - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: integer - example: 10751 - name: - type: string - example: Family - /genres/tv: - get: - summary: Get list of official TMDB movie genres - description: Returns a list of genres in a JSON array. - tags: - - tmdb - parameters: - - in: query - name: language - schema: - type: string - example: en - responses: - '200': - description: Results - content: - application/json: - schema: - type: array - items: - type: object - properties: - id: - type: integer - example: 18 - name: - type: string - example: Drama - /backdrops: - get: - summary: Get backdrops of trending items - description: Returns a list of backdrop image paths in a JSON array. - security: [] - tags: - - tmdb - responses: - '200': - description: Results - content: - application/json: - schema: - type: array - items: - type: string - /issue: - get: - summary: Get all issues - description: | - Returns a list of issues in JSON format. - tags: - - issue - parameters: - - in: query - name: take - schema: - type: integer - nullable: true - example: 20 - - in: query - name: skip - schema: - type: integer - nullable: true - example: 0 - - in: query - name: sort - schema: - type: string - enum: [added, modified] - default: added - - in: query - name: filter - schema: - type: string - enum: [all, open, resolved] - default: open - - in: query - name: requestedBy - schema: - type: integer - nullable: true - example: 1 - responses: - '200': - description: Issues returned - content: - application/json: - schema: - type: object - properties: - pageInfo: - $ref: '#/components/schemas/PageInfo' - results: - type: array - items: - $ref: '#/components/schemas/Issue' - post: - summary: Create new issue - description: | - Creates a new issue - tags: - - issue - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - issueType: - type: integer - message: - type: string - mediaid: - type: integer - responses: - '201': - description: Succesfully created the issue - content: - application/json: - schema: - $ref: '#/components/schemas/Issue' - - /issue/count: - get: - summary: Gets issue counts - description: | - Returns the number of open and closed issues, as well as the number of issues of each type. - tags: - - issue - responses: - '200': - description: Issue counts returned - content: - application/json: - schema: - type: object - properties: - total: - type: integer - video: - type: integer - audio: - type: integer - subtitles: - type: integer - others: - type: integer - open: - type: integer - closed: - type: integer - /issue/{issueId}: - get: - summary: Get issue - description: | - Returns a single issue in JSON format. - tags: - - issue - parameters: - - in: path - name: issueId - required: true - schema: - type: integer - example: 1 - responses: - '200': - description: Issues returned - content: - application/json: - schema: - $ref: '#/components/schemas/Issue' - delete: - summary: Delete issue - description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. - tags: - - issue - parameters: - - in: path - name: issueId - description: Issue ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed issue - /issue/{issueId}/comment: - post: - summary: Create a comment - description: | - Creates a comment and returns associated issue in JSON format. - tags: - - issue - parameters: - - in: path - name: issueId - required: true - schema: - type: integer - example: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - message: - type: string - required: - - message - responses: - '200': - description: Issue returned with new comment - content: - application/json: - schema: - $ref: '#/components/schemas/Issue' - /issueComment/{commentId}: - get: - summary: Get issue comment - description: | - Returns a single issue comment in JSON format. - tags: - - issue - parameters: - - in: path - name: commentId - required: true - schema: - type: string - example: 1 - responses: - '200': - description: Comment returned - content: - application/json: - schema: - $ref: '#/components/schemas/IssueComment' - put: - summary: Update issue comment - description: | - Updates and returns a single issue comment in JSON format. - tags: - - issue - parameters: - - in: path - name: commentId - required: true - schema: - type: string - example: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - message: - type: string - responses: - '200': - description: Comment updated - content: - application/json: - schema: - $ref: '#/components/schemas/IssueComment' - delete: - summary: Delete issue comment - description: | - Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. - tags: - - issue - parameters: - - in: path - name: commentId - description: Issue Comment ID - required: true - example: '1' - schema: - type: string - responses: - '204': - description: Succesfully removed issue comment - /issue/{issueId}/{status}: - post: - summary: Update an issue's status - description: | - Updates an issue's status to approved or declined. Also returns the issue in a JSON object. - - Requires the `MANAGE_ISSUES` permission or `ADMIN`. - tags: - - issue - parameters: - - in: path - name: issueId - description: Issue ID - required: true - schema: - type: string - example: '1' - - in: path - name: status - description: New status - required: true - schema: - type: string - enum: [open, resolved] - responses: - '200': - description: Issue status changed - content: - application/json: - schema: - $ref: '#/components/schemas/Issue' - /keyword/{keywordId}: - get: - summary: Get keyword - description: | - Returns a single keyword in JSON format. - tags: - - other - parameters: - - in: path - name: keywordId - required: true - schema: - type: integer - example: 1 - responses: - '200': - description: Keyword returned (null if not found) - content: - application/json: - schema: - nullable: true - $ref: '#/components/schemas/Keyword' - '500': - description: Internal server error - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: 'Unable to retrieve keyword data.' - /watchproviders/regions: - get: - summary: Get watch provider regions - description: | - Returns a list of all available watch provider regions. - tags: - - other - responses: - '200': - description: Watch provider regions returned - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/WatchProviderRegion' - /watchproviders/movies: - get: - summary: Get watch provider movies - description: | - Returns a list of all available watch providers for movies. - tags: - - other - parameters: - - in: query - name: watchRegion - required: true - schema: - type: string - example: US - responses: - '200': - description: Watch providers for movies returned - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/WatchProviderDetails' - /watchproviders/tv: - get: - summary: Get watch provider series - description: | - Returns a list of all available watch providers for series. - tags: - - other - parameters: - - in: query - name: watchRegion - required: true - schema: - type: string - example: US - responses: - '200': - description: Watch providers for series returned - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/WatchProviderDetails' - /certifications/movie: - get: - summary: Get movie certifications - description: Returns list of movie certifications from TMDB. - tags: - - other - security: - - cookieAuth: [] - - apiKey: [] - responses: - '200': - description: Movie certifications returned - content: - application/json: - schema: - $ref: '#/components/schemas/CertificationResponse' - '500': - description: Unable to retrieve movie certifications - content: - application/json: - schema: - type: object - properties: - status: - type: integer - example: 500 - message: - type: string - example: Unable to retrieve movie certifications. - /certifications/tv: - get: - summary: Get TV certifications - description: Returns list of TV show certifications from TMDB. - tags: - - other - security: - - cookieAuth: [] - - apiKey: [] - responses: - '200': - description: TV certifications returned - content: - application/json: - schema: - $ref: '#/components/schemas/CertificationResponse' - '500': - description: Unable to retrieve TV certifications - content: - application/json: - schema: - type: object - properties: - status: - type: integer - example: 500 - message: - type: string - example: Unable to retrieve TV certifications. - /overrideRule: - get: - summary: Get override rules - description: Returns a list of all override rules with their conditions and settings - tags: - - overriderule - responses: - '200': - description: Override rules returned - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/OverrideRule' - post: - summary: Create override rule - description: Creates a new Override Rule from the request body. - tags: - - overriderule - responses: - '200': - description: 'Values were successfully created' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/OverrideRule' - /overrideRule/{ruleId}: - put: - summary: Update override rule - description: Updates an Override Rule from the request body. - tags: - - overriderule - parameters: - - in: path - name: ruleId - required: true - schema: - type: integer - responses: - '200': - description: 'Values were successfully updated' - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/OverrideRule' - delete: - summary: Delete override rule by ID - description: Deletes the override rule with the provided ruleId. - tags: - - overriderule - parameters: - - in: path - name: ruleId - required: true - schema: - type: integer - responses: - '200': - description: Override rule successfully deleted - content: - application/json: - schema: - $ref: '#/components/schemas/OverrideRule' -security: - - cookieAuth: [] - - apiKey: [] diff --git a/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache deleted file mode 100644 index 33cec807..00000000 --- a/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache +++ /dev/null @@ -1,191 +0,0 @@ -package {{packageName}}.infrastructure - -{{#moshi}} -import com.squareup.moshi.Moshi -{{#enumUnknownDefaultCase}} -import com.squareup.moshi.adapters.EnumJsonAdapter -{{/enumUnknownDefaultCase}} -{{^moshiCodeGen}} -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -{{/moshiCodeGen}} -{{/moshi}} -{{#gson}} -import com.google.gson.Gson -import com.google.gson.GsonBuilder -{{^threetenbp}} -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -{{/threetenbp}} -{{#threetenbp}} -import org.threeten.bp.LocalDate -import org.threeten.bp.LocalDateTime -import org.threeten.bp.OffsetDateTime -{{/threetenbp}} -{{#kotlinx-datetime}} -import kotlin.time.Instant -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -{{/kotlinx-datetime}} -import java.util.UUID -{{/gson}} -{{#jackson}} -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -{{/jackson}} -{{#kotlinx_serialization}} -import java.math.BigDecimal -import java.math.BigInteger -{{^threetenbp}} -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.OffsetDateTime -{{/threetenbp}} -{{#threetenbp}} -import org.threeten.bp.LocalDate -import org.threeten.bp.LocalDateTime -import org.threeten.bp.OffsetDateTime -{{/threetenbp}} -import java.util.UUID -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonBuilder -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.SerializersModuleBuilder -import java.net.URI -import java.net.URL -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong -{{/kotlinx_serialization}} - -{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object Serializer { -{{#moshi}} - @JvmStatic - {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshiBuilder: Moshi.Builder = Moshi.Builder() - .add(OffsetDateTimeAdapter()) - {{#kotlinx-datetime}} - .add(InstantAdapter()) - .add(LocalDateAdapter()) - .add(LocalTimeAdapter()) - {{/kotlinx-datetime}} - .add(LocalDateTimeAdapter()) - .add(LocalDateAdapter()) - .add(UUIDAdapter()) - .add(ByteArrayAdapter()) - .add(URIAdapter()) - {{^moshiCodeGen}} - .add(KotlinJsonAdapterFactory()) - {{/moshiCodeGen}} - .add(BigDecimalAdapter()) - .add(BigIntegerAdapter()) - - @JvmStatic - {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshi: Moshi by lazy { -{{#enumUnknownDefaultCase}} - SerializerHelper.addEnumUnknownDefaultCase(moshiBuilder) -{{/enumUnknownDefaultCase}} - moshiBuilder.build() - } -{{/moshi}} -{{#gson}} - @JvmStatic - val gsonBuilder: GsonBuilder = GsonBuilder() - .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdapter()) - {{#kotlinx-datetime}} - .registerTypeAdapter(Instant::class.java, InstantAdapter()) - .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) - .registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter()) - {{/kotlinx-datetime}} - .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) - .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) - .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) - {{#generateOneOfAnyOfWrappers}} - {{#models}} - {{#model}} - {{^isEnum}} - {{^hasChildren}} - .registerTypeAdapterFactory({{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory()) - {{/hasChildren}} - {{/isEnum}} - {{/model}} - {{/models}} - {{/generateOneOfAnyOfWrappers}} - - @JvmStatic - val gson: Gson by lazy { - gsonBuilder.create() - } -{{/gson}} -{{#jackson}} - @JvmStatic - val jacksonObjectMapper: ObjectMapper = jacksonObjectMapper() - .findAndRegisterModules() - .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) - {{#enumUnknownDefaultCase}} - .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) - {{/enumUnknownDefaultCase}} - .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, {{failOnUnknownProperties}}) -{{/jackson}} -{{#kotlinx_serialization}} - private var isAdaptersInitialized = false - - @JvmStatic - val kotlinxSerializationAdapters: SerializersModule by lazy { - isAdaptersInitialized = true - SerializersModule { - contextual(BigDecimal::class, BigDecimalAdapter) - contextual(BigInteger::class, BigIntegerAdapter) - {{^kotlinx-datetime}} - contextual(LocalDate::class, LocalDateAdapter) - contextual(LocalDateTime::class, LocalDateTimeAdapter) - contextual(OffsetDateTime::class, OffsetDateTimeAdapter) - {{/kotlinx-datetime}} - contextual(UUID::class, UUIDAdapter) - contextual(AtomicInteger::class, AtomicIntegerAdapter) - contextual(AtomicLong::class, AtomicLongAdapter) - contextual(AtomicBoolean::class, AtomicBooleanAdapter) - contextual(URI::class, URIAdapter) - contextual(URL::class, URLAdapter) - contextual(StringBuilder::class, StringBuilderAdapter) - - apply(kotlinxSerializationAdaptersConfiguration) - } - } - - var kotlinxSerializationAdaptersConfiguration: SerializersModuleBuilder.() -> Unit = {} - set(value) { - check(!isAdaptersInitialized) { - "Cannot configure kotlinxSerializationAdaptersConfiguration after kotlinxSerializationAdapters has been initialized." - } - field = value - } - - private var isJsonInitialized = false - - @JvmStatic - val kotlinxSerializationJson: Json by lazy { - isJsonInitialized = true - Json { - serializersModule = kotlinxSerializationAdapters - encodeDefaults = true - ignoreUnknownKeys = true - isLenient = true - explicitNulls = false - - apply(kotlinxSerializationJsonConfiguration) - } - } - - var kotlinxSerializationJsonConfiguration: JsonBuilder.() -> Unit = {} - set(value) { - check(!isJsonInitialized) { - "Cannot configure kotlinxSerializationJsonConfiguration after kotlinxSerializationJson has been initialized." - } - field = value - } -{{/kotlinx_serialization}} -} diff --git a/app/src/optimized/baselineProfiles/baseline-prof.txt b/app/src/optimized/baselineProfiles/baseline-prof.txt deleted file mode 100644 index ad4df0ae..00000000 --- a/app/src/optimized/baselineProfiles/baseline-prof.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles. -Lcom/github/damontecres/wholphin/WholphinApplication; -HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V -Lcom/github/damontecres/wholphin/MainActivity; -HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V -Lorg/justinvisser/wolfskin/tv/WolfskinAppKt; -Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference; -Lorg/justinvisser/wolfskin/tv/ConductarrPreference; diff --git a/app/src/optimized/res/values/strings.xml b/app/src/optimized/res/values/strings.xml deleted file mode 100644 index fa4e585f..00000000 --- a/app/src/optimized/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="app_name" translatable="false">Wolfskin (Opt)</string> -</resources> diff --git a/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt b/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt deleted file mode 100644 index b49e7828..00000000 --- a/app/src/release/java/org/justinvisser/wolfskin/tv/DebugHome.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import org.justinvisser.wolfskin.design.HomeSnapshot - -/** Release Home always reads the selected Jellyfin identity, never prototype data. */ -@Suppress("UNUSED_PARAMETER") -fun debugHomeSnapshot(context: Context, person: String): HomeSnapshot? = null diff --git a/app/src/test/java/android/text/TextUtils.java b/app/src/test/java/android/text/TextUtils.java deleted file mode 100644 index 959d7957..00000000 --- a/app/src/test/java/android/text/TextUtils.java +++ /dev/null @@ -1,9 +0,0 @@ -package android.text; - -// Mocks static class for non-instrumented unit tests - -public class TextUtils { - public static boolean isEmpty(CharSequence str) { - return str == null || str.length() == 0; - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt deleted file mode 100644 index 1de5becd..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ /dev/null @@ -1,432 +0,0 @@ -package com.github.damontecres.wholphin - -import android.content.Context -import android.content.Intent -import android.widget.Toast -import androidx.datastore.core.DataStore -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -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.test.currentUser -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.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.runTest -import org.jellyfin.sdk.model.UUID -import org.junit.After -import org.junit.Assert -import org.junit.Assert.assertFalse -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() - - val viewModel = - MainActivityViewModel( - context, - preferences, - serverRepository, - setupNavigationManager, - navigationManager, - deviceProfileService, - backdropService, - appUpgradeHandler, - intentService, - ) - - private val serverId = UUID.randomUUID() - private val server = server(serverId) - 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 - 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 - } - every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser -// coEvery { serverRepository.restoreLastSession() } returns -// RestoredSession.Success(currentUser) - - viewModel.handleAppStart() - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) - destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) - } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } - coVerify(exactly = 0) { serverRepository.getMostRecentServer() } - } - - @Test - fun `Test auto sign disabled in with current server`() = - runTest { - setupPreferences { - signInAutomatically = false - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) -// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser - coEvery { serverRepository.getMostRecentServer() } returns - RestoredSession.ServerOnly(server) - - viewModel.handleAppStart() - - coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } - coVerify(exactly = 1) { serverRepository.getMostRecentServer() } - val args = mutableListOf<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(args)) } - Assert.assertEquals(1, args.size) - args[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) - destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) - } - } - - @Test - fun `Test auto sign in, hot load, with protected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns - MutableStateFlow<CurrentUser?>( - protectedCurrentUser, - ) - coEvery { - serverRepository.tryChangeUser(serverId, userId) - } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(server) - - viewModel.handleAppStart() - - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - val args = mutableListOf<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(args)) } - Assert.assertEquals(1, args.size) - args[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) - destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) - } - } - - @Test - fun `Test auto sign in, cold load, with protected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(server) - coEvery { - serverRepository.tryChangeUser(serverId, userId) - } returns null - - viewModel.handleAppStart() - - coVerify(exactly = 1) { serverRepository.restoreLastSession() } - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) - destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) - } - } - - @Test - fun `Test auto sign in, no current user`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None - - viewModel.handleAppStart() - - coVerify(exactly = 1) { serverRepository.restoreLastSession() } - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) - } - } - - @Test - fun `Test intent result is noop, hot reload`() = - runTest(testDispatcher) { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) - coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp - - val result = viewModel.handleIntent(Intent()) - assertFalse(result) - -// val slot = slot<SetupDestination>() -// verify { setupNavigationManager.navigateTo(capture(slot)) } -// slot.captured.let { destination -> -// Assert.assertTrue( -// "destination is ${destination::class}", -// destination is SetupDestination.AppContent, -// ) -// destination as SetupDestination.AppContent -// Assert.assertEquals(currentUser, destination.current) -// } - coVerify(exactly = 0) { serverRepository.restoreLastSession() } - coVerify(exactly = 0) { serverRepository.getMostRecentServer() } - } - - @Test - fun `Test intent result is error, has server`() = - runTest(testDispatcher) { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - - val result = viewModel.handleIntent(Intent()) - assertTrue(result) - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.UserList, - ) - destination as SetupDestination.UserList - Assert.assertEquals(currentUser.server, destination.server) - } - } - - @Test - fun `Test intent result is error, does not have server`() = - runTest(testDispatcher) { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow(null) - coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - - val result = viewModel.handleIntent(Intent()) - assertTrue(result) - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) - } - } - - @Test - fun `Test intent result is target, has server`() = - runTest(testDispatcher) { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(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 - - val result = viewModel.handleIntent(Intent()) - assertTrue(result) - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - verify { navigationManager.reloadHome() } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) - destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) - } - // Note: navigation manager internally handles Home destination, so it won't be in this list - Assert.assertEquals(1, backstack.size) - backstack[0].let { destination -> - Assert.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 - } - every { serverRepository.current } returns MutableStateFlow(currentUser) -// coEvery { serverRepository.tryRestoreSession(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 - - val result = viewModel.handleIntent(Intent()) - assertTrue(result) - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - verify(exactly = 0) { navigationManager.reloadHome() } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.AppContent, - ) - destination as SetupDestination.AppContent - Assert.assertEquals(currentUser, destination.current) - } - Assert.assertEquals(1, backstack.size) - backstack[0].let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is Destination.Favorites, - ) - } - } - - @Test - fun `Test intent result is target, has no server`() = - runTest(testDispatcher) { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow(null) -// coEvery { serverRepository.tryRestoreSession(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 - - val result = viewModel.handleIntent(Intent()) - assertTrue(result) - - val slot = slot<SetupDestination>() - verify { setupNavigationManager.navigateTo(capture(slot)) } - verify(exactly = 0) { navigationManager.reloadHome() } - slot.captured.let { destination -> - Assert.assertTrue( - "destination is ${destination::class}", - destination is SetupDestination.ServerList, - ) - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt deleted file mode 100644 index 14d59cf2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.damontecres.wholphin - -import org.junit.Assert.assertEquals -import org.junit.Test - -class WolfskinDefaultsTest { - @Test - fun emptyUrlIsPrefilledWithWolfskinSeerrUrl() { - assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill()) - assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill(" ")) - } - - @Test - fun enteredUrlIsKept() { - assertEquals("https://seerr.example.org", seerrUrlPrefill("https://seerr.example.org")) - } -} 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 deleted file mode 100644 index 0b18cc8e..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/AlphabetSortNameTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -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/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt deleted file mode 100644 index 11006322..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -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.Assert.assertNull -import org.junit.Test -import java.util.UUID - -class BaseItemSubtitleTest { - private fun album( - albumArtist: String? = null, - artists: List<String>? = null, - productionYear: Int? = null, - ) = BaseItem( - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.MUSIC_ALBUM, - name = "Test Album", - albumArtist = albumArtist, - artists = artists, - productionYear = productionYear, - ), - ) - - @Test - fun albumPrefersAlbumArtist() { - val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021) - assertEquals("Album Artist - 2021", item.subtitle) - } - - @Test - fun albumFallsBackToArtists() { - val item = album(artists = listOf("A", "B"), productionYear = 2021) - assertEquals("A, B - 2021", item.subtitle) - } - - @Test - fun albumWithoutYear() { - assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle) - } - - @Test - fun albumWithoutArtist() { - assertEquals("2021", album(productionYear = 2021).subtitle) - assertNull(album(artists = emptyList()).subtitle) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt deleted file mode 100644 index 283ee8ca..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import com.github.damontecres.wholphin.test.TestTracks -import com.github.damontecres.wholphin.test.assertNotNull -import org.junit.Assert.assertEquals -import org.junit.Test - -class RelativeMediaStreamTest { - @Test - fun `Test getRelativeIndex`() { - val source = - TestTracks - .Builder() - .addSubtitle(2) - .addAudio(2) - .addVideo() - .buildMediaSourceInfo() - val streams = source.mediaStreams!! - RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex -> - assertNotNull(relativeIndex) - assertEquals(0, relativeIndex) - } - RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex -> - assertNotNull(relativeIndex) - assertEquals(1, relativeIndex) - } - RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex -> - assertNotNull(relativeIndex) - assertEquals(0, relativeIndex) - } - RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex -> - assertNotNull(relativeIndex) - assertEquals(1, relativeIndex) - } - } - - @Test - fun `Test getRelativeIndex for stream`() { - val source = - TestTracks - .Builder() - .addSubtitle(2) - .addAudio(2) - .addVideo() - .buildMediaSourceInfo() - val streams = source.mediaStreams!! - RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex -> - assertNotNull(relativeIndex) - assertEquals(1, relativeIndex) - } - } - - @Test - fun `Test asRelative basic`() { - val source = - TestTracks - .Builder() - .addSubtitle(2) - .addAudio(2) - .addVideo() - .buildMediaSourceInfo() - val streams = source.mediaStreams!! - assertEquals(5, streams.size) - - val relative = streams.asRelative() - // Subtitle - assertEquals(0, relative[0].relativeIndex) - assertEquals(0, relative[0].stream.index) - assertEquals(1, relative[1].relativeIndex) - assertEquals(1, relative[1].stream.index) - - // Audio - assertEquals(0, relative[2].relativeIndex) - assertEquals(2, relative[2].stream.index) - assertEquals(1, relative[3].relativeIndex) - assertEquals(3, relative[3].stream.index) - - // Video - assertEquals(0, relative[4].relativeIndex) - assertEquals(4, relative[4].stream.index) - } - - @Test - fun `Test asRelative with external`() { - val source = - TestTracks - .Builder() - .addExternalSubtitle(2) - .addSubtitle(2) - .addAudio(2) - .buildMediaSourceInfo() - val streams = source.mediaStreams!! - assertEquals(6, streams.size) - - val relative = streams.asRelative() - // External Subtitle - assertEquals(0, relative[0].relativeIndex) - assertEquals(0, relative[0].stream.index) - assertEquals(1, relative[1].relativeIndex) - assertEquals(1, relative[1].stream.index) - - // Subtitle - assertEquals(0, relative[2].relativeIndex) - assertEquals(2, relative[2].stream.index) - assertEquals(1, relative[3].relativeIndex) - assertEquals(3, relative[3].stream.index) - - // Audio - assertEquals(0, relative[4].relativeIndex) - assertEquals(4, relative[4].stream.index) - assertEquals(1, relative[5].relativeIndex) - assertEquals(5, relative[5].stream.index) - } - - @Test - fun `Test asRelative with external mixed`() { - val source = - TestTracks - .Builder() - .addExternalSubtitle(1) - .addSubtitle(1) - .addAudio(1) - .addExternalSubtitle(1) - .addAudio(1) - .addSubtitle(1) - .buildMediaSourceInfo() - val streams = source.mediaStreams!! - assertEquals(6, streams.size) - - val relative = streams.asRelative() - // External Subtitle - assertEquals(0, relative[0].relativeIndex) - assertEquals(0, relative[0].stream.index) - assertEquals(1, relative[3].relativeIndex) - assertEquals(3, relative[3].stream.index) - - // Subtitle - assertEquals(0, relative[1].relativeIndex) - assertEquals(1, relative[1].stream.index) - assertEquals(1, relative[5].relativeIndex) - assertEquals(5, relative[5].stream.index) - - // Audio - assertEquals(0, relative[2].relativeIndex) - assertEquals(2, relative[2].stream.index) - assertEquals(1, relative[4].relativeIndex) - assertEquals(4, relative[4].stream.index) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt deleted file mode 100644 index e5c12a56..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.github.damontecres.wholphin.data.model - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class SourceRatingsTest { - @Test - fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() { - val ratings = - SourceRatings.of( - rtCriticsScore = 96, - rtCriticsLabel = "Certified Fresh", - rtAudienceScore = 90, - rtAudienceLabel = "Upright", - imdbScore = 8.8, - imdbVotes = 2_500_000, - tmdbScore = 8.4, - tmdbVotes = 30_000, - ) - assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics) - assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience) - assertEquals(8.8, ratings.imdb!!, 0.0) - assertNull(ratings.tmdb) - } - - @Test - fun tmdbIsShownOnlyWhenNothingElseIs() { - val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120) - assertEquals(7.633, ratings.tmdb!!, 0.0) - assertNull(ratings.rtCritics) - assertNull(ratings.imdb) - assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text }) - - // A single non-TMDB source hides TMDB - assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb) - assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb) - } - - @Test - fun zeroVoteScoresAreUnratedNotZero() { - val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0) - assertTrue(ratings.isEmpty) - - // A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback - val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55) - assertNull(withTmdb.imdb) - assertEquals(7.1, withTmdb.tmdb!!, 0.0) - } - - @Test - fun missingVoteCountIsStillObserved() { - assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0) - } - - @Test - fun outOfRangeScoresAreDropped() { - assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty) - assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty) - assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty) - } - - @Test - fun rtMarkComesFromSeerrLabelNotThePercentage() { - // 95% but labelled Rotten and 10% labelled Fresh: the label wins - assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark) - assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark) - assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark) - assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark) - assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark) - } - - @Test - fun rtWithoutKnownLabelUsesPwaDefaults() { - assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark) - assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark) - assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark) - } - - @Test - fun statusLabelMapping() { - assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh")) - assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh")) - assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten")) - assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright")) - assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled")) - assertNull(RtMark.fromLabel(null)) - assertNull(RtMark.fromLabel("fresh")) - } - - @Test - fun entriesUseThePwaTextAndBadges() { - val ratings = - SourceRatings.of( - rtCriticsScore = 85, - rtCriticsLabel = "Fresh", - rtAudienceScore = 40, - rtAudienceLabel = "Spilled", - imdbScore = 8.0, - imdbVotes = 1, - ) - assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) - assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge }) - assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) - assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge }) - } - - @Test - fun everyRtMarkHasItsOwnBadge() { - val badges = - listOf( - RtMark.FRESH to RatingBadge.RT_FRESH, - RtMark.ROTTEN to RatingBadge.RT_ROTTEN, - RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT, - RtMark.SPILLED to RatingBadge.RT_SPILLED, - RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN, - ) - badges.forEach { (mark, badge) -> - assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge) - } - } - - private val movie = - SourceRatings.of( - rtCriticsScore = 96, - rtCriticsLabel = "Certified Fresh", - rtAudienceScore = 59, - rtAudienceLabel = "Spilled", - imdbScore = 8.8, - imdbVotes = 10, - ) - - @Test - fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() { - // Rotten Tomatoes (critic rating setting) before IMDb (community rating setting) - assertEquals( - listOf("96%", "59%", "8.8"), - movie.visibleEntries(community = true, critic = true).map { it.text }, - ) - assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text }) - assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text }) - assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty()) - } - - @Test - fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() { - val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true) - assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) - assertEquals(listOf("7.6"), entries.map { it.text }) - } - - @Test - fun noEntriesWithoutRatings() { - assertTrue(SourceRatings().isEmpty) - assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) - assertFalse(movie.isEmpty) - } - - @Test - fun episodeScoreFollowsThePwaRulesForAUserScore() { - assertEquals(7.9, SourceRatings.episodeScore(7.9, 120)!!, 0.0) - // unrated, never 0 - assertNull(SourceRatings.episodeScore(0.0, 0)) - assertNull(SourceRatings.episodeScore(8.0, 0)) - assertNull(SourceRatings.episodeScore(8.0, null)) - // not observed or out of range - assertNull(SourceRatings.episodeScore(null, 10)) - assertNull(SourceRatings.episodeScore(10.5, 10)) - assertNull(SourceRatings.episodeScore(-1.0, 10)) - assertEquals(10.0, SourceRatings.episodeScore(10.0, 1)!!, 0.0) - } - - @Test - fun episodeKeepsTheSeriesRottenTomatoesAndHasOneLabelledTmdbNumber() { - val series = - SourceRatings.of(rtCriticsScore = 63, rtCriticsLabel = "Fresh", rtAudienceScore = 71, rtAudienceLabel = "Upright", tmdbScore = 7.6, tmdbVotes = 3000) - // the series only has its TMDB score because it has no other source - val withTmdbOnly = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 3000) - assertEquals(7.6, withTmdbOnly.tmdb!!, 0.0) - - val episode = withTmdbOnly.withEpisode(8.2) - assertNull(episode.tmdb) - val entries = episode.visibleEntries(community = true, critic = true) - assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) - assertEquals(listOf("8.2"), entries.map { it.text }) - assertEquals(listOf(true), entries.map { it.isEpisode }) - - val full = series.withEpisode(8.2).visibleEntries(community = true, critic = true) - assertEquals(listOf("63%", "71%", "8.2"), full.map { it.text }) - assertEquals(listOf(false, false, true), full.map { it.isEpisode }) - // the display settings treat it as a community (IMDb/TMDB) rating - assertEquals(listOf("8.2"), series.withEpisode(8.2).visibleEntries(community = true, critic = false).map { it.text }) - assertEquals(listOf("63%", "71%"), series.withEpisode(8.2).visibleEntries(community = false, critic = true).map { it.text }) - } - - @Test - fun withoutAnEpisodeScoreTheSeriesRatingsAreUnchanged() { - val series = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 10) - assertEquals(series, series.withEpisode(null)) - assertFalse(SourceRatings().withEpisode(5.0).isEmpty) - } -} 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 deleted file mode 100644 index 3998201f..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ExtrasServiceTest.kt +++ /dev/null @@ -1,183 +0,0 @@ -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/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt deleted file mode 100644 index d179c7e1..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.data.model.BaseItem -import io.mockk.every -import io.mockk.mockk -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.api.operations.ImageApi -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.ImageType -import org.jellyfin.sdk.model.serializer.toUUID -import org.junit.Assert -import org.junit.Before -import org.junit.Test - -class ImageUrlServiceTest { - private val mockApiClient = mockk<ApiClient>() - private val imageUrlService = ImageUrlService(mockApiClient) - - private val itemId = UUID.randomUUID() - private val seriesId = UUID.randomUUID() - private val seasonId = UUID.randomUUID() - - @Before - fun setup() { - every { mockApiClient.baseUrl } returns "http://localhost" - every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() } - every { mockApiClient.imageApi } returns ImageApi(mockApiClient) - } - - private fun episode( - imageTags: Map<ImageType, String> = emptyMap(), - parentLogoItemId: UUID? = null, - parentThumbItemId: UUID? = null, - parentBackdropItemId: UUID? = null, - ) = BaseItemDto( - id = itemId, - type = BaseItemKind.EPISODE, - seriesId = seriesId, - seasonId = seasonId, - imageTags = imageTags, - seriesPrimaryImageTag = "seriesPrimary", - parentLogoItemId = parentLogoItemId, - parentLogoImageTag = parentLogoItemId?.let { "parentLogo" }, - parentThumbItemId = parentThumbItemId, - parentThumbImageTag = parentThumbItemId?.let { "parentThumb" }, - parentBackdropItemId = parentBackdropItemId, - parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") }, - ) - - private fun movie( - imageTags: Map<ImageType, String> = emptyMap(), - backdropImageTags: List<String>? = null, - ) = BaseItemDto( - id = itemId, - type = BaseItemKind.MOVIE, - imageTags = imageTags, - backdropImageTags = backdropImageTags, - ) - - private fun assertImage( - url: String?, - expectedId: UUID, - expectedType: ImageType, - expectedTag: String?, - ) { - Assert.assertNotNull(url) - val path = url!!.substringBefore('?') - Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID()) - Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/")) - val tag = - url - .substringAfter('?', "") - .split('&') - .firstOrNull { it.startsWith("tag=") } - ?.substringAfter("tag=") - Assert.assertEquals(expectedTag, tag) - } - - @Test - fun `Own images use own tags`() { - val item = - BaseItem( - movie( - imageTags = - mapOf( - ImageType.PRIMARY to "primary", - ImageType.LOGO to "logo", - ImageType.THUMB to "thumb", - ImageType.BANNER to "banner", - ImageType.ART to "art", - ), - backdropImageTags = listOf("backdrop0", "backdrop1"), - ), - ) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0") - } - - @Test - fun `Missing tags are omitted`() { - val item = BaseItem(movie()) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null) - Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)) - } - - @Test - fun `Thumb falls back to own backdrop`() { - val item = BaseItem(movie(backdropImageTags = listOf("backdrop0"))) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0") - } - - @Test - fun `Episode primary uses series tag`() { - val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") - assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null) - assertImage( - imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false), - itemId, - ImageType.PRIMARY, - "primary", - ) - } - - @Test - fun `Season without primary uses series tag`() { - val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null)) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") - } - - @Test - fun `Episode logo uses parent logo tag only if from series`() { - assertImage( - imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO), - seriesId, - ImageType.LOGO, - "parentLogo", - ) - assertImage( - imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO), - seriesId, - ImageType.LOGO, - null, - ) - } - - @Test - fun `Episode backdrop uses parent backdrop tag only if from series`() { - assertImage( - imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP), - seriesId, - ImageType.BACKDROP, - "parentBackdrop0", - ) - assertImage( - imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP), - seriesId, - ImageType.BACKDROP, - null, - ) - } - - @Test - fun `Episode thumb uses parent tags`() { - assertImage( - imageUrlService.getItemImageUrl( - BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true), - ImageType.THUMB, - ), - seriesId, - ImageType.THUMB, - "parentThumb", - ) - assertImage( - imageUrlService.getItemImageUrl( - BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true), - ImageType.THUMB, - ), - seriesId, - ImageType.BACKDROP, - "parentBackdrop0", - ) - assertImage( - imageUrlService.getItemImageUrl( - BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true), - ImageType.THUMB, - ), - itemId, - ImageType.PRIMARY, - "primary", - ) - } - - @Test - fun `Season without thumb uses parent thumb tag`() { - val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON)) - assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb") - } -} 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 deleted file mode 100644 index 2bcc6ab7..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ /dev/null @@ -1,470 +0,0 @@ -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.RestoredSession -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 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 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)) - } - - 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 - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) - coEvery { serverRepository.tryChangeUser(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.restoreLastSession() } - } - - @Test - fun `Test auto sign disabled in with unprotected profile`() = - runTest { - setupPreferences { - signInAutomatically = false - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser) - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(currentUser) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser - - val intent = Intent() - val result = intentService.prepare(intent) - assertTrue(result is IntentResult.Error) - - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - } - - @Test - fun `Test auto sign in, hot load, with protected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(protectedCurrentUser.server) - - val intent = Intent() - val result = intentService.prepare(intent) - assertTrue(result is IntentResult.Error) - - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - } - - @Test - fun `Test auto sign in, cold load, with protected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.ServerOnly(protectedCurrentUser.server) - - val intent = Intent() - val result = intentService.prepare(intent) - assertTrue(result is IntentResult.Error) - - coVerify(exactly = 1) { serverRepository.restoreLastSession() } - } - - @Test - fun `Test auto sign in, no current user`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None -// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser - - val intent = Intent() - val result = intentService.prepare(intent) - assertTrue(result is IntentResult.Error) - - coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } - } - - @Test - fun `Test user specified with unprotected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser - coEvery { serverRepository.restoreLastSession() } returns - RestoredSession.Success(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.tryChangeUser(serverId, userId) } - } - - @Test - fun `Test user specified with protected profile`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null -// coEvery { serverRepository.restoreLastSession() } returns -// RestoredSession.ServerOnly(protectedCurrentUser.server) - - 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 = 1) { serverRepository.tryChangeUser(serverId, userId) } - } - - @Test - fun `Test user specified does not exist`() = - runTest { - setupPreferences { - signInAutomatically = true - } - every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null) - coEvery { serverRepository.tryChangeUser(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 = 1) { serverRepository.tryChangeUser(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.tryChangeUser( - newServerId, - newUserId, - ) - } returns newCurrentUser - - 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.tryChangeUser(serverId, userId) } - coVerify { serverRepository.tryChangeUser(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.tryChangeUser( - newServerId, - newUserId, - ) - } returns newCurrentUser - - 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.tryChangeUser(serverId, userId) } - coVerify { serverRepository.tryChangeUser(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 deleted file mode 100644 index 256ee9d8..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.appcompat.app.AppCompatActivity -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.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.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class LatestNextUpSchedulerServiceTest { - @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private val currentUser = MutableStateFlow<CurrentUser?>(null) - private val mockActivity = mockk<AppCompatActivity>(relaxed = true) - private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockWorkManager = mockk<WorkManager>(relaxed = true) - private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) - - @Before - fun setUp() { - WholphinDispatchers.configure(testDispatcher) - every { mockActivity.lifecycle } returns lifecycleRegistry - every { mockServerRepository.current } returns currentUser - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - } - - @After - fun tearDown() = WholphinDispatchers.reset() - - private fun createService() = - LatestNextUpSchedulerService( - context = mockActivity, - serverRepository = mockServerRepository, - workManager = mockWorkManager, - ).also { - it.dispatcher = testDispatcher - } - - @Test - fun cancels_latestNextUp_work_when_user_null() = - runTest(testDispatcher) { - createService() - - currentUser.value = null - advanceUntilIdle() - - verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } - verify(exactly = 0) { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } - } -} 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 deleted file mode 100644 index d4590cf7..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt +++ /dev/null @@ -1,187 +0,0 @@ -@file:UseSerializers( - UUIDSerializer::class, - DateTimeSerializer::class, -) - -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.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.UseSerializers -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.Response -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.operations.TvShowsApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.DisplayPreferencesDto -import org.jellyfin.sdk.model.api.ScrollDirection -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.UserItemDataDto -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.serializer.DateTimeSerializer -import org.jellyfin.sdk.model.serializer.UUIDSerializer -import org.junit.After -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import java.time.LocalDateTime -import java.util.UUID - -class LatestNextUpServiceTests { - private val testDispatcher = StandardTestDispatcher() - - private val userId = UUID.randomUUID() - private val seriesId1 = UUID.randomUUID() - private val seriesId2 = UUID.randomUUID() - private val seriesId3 = UUID.randomUUID() - - private val mockApi = mockk<ApiClient>(relaxed = true) - private val mockTvShowsApi = mockk<TvShowsApi>() - private val mockDatePlayedService = mockk<DatePlayedService>() - private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>() - private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true) - - private val latestNextUpService = - LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager) - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - WholphinDispatchers.configure(testDispatcher) - every { mockApi.tvShowsApi } returns mockTvShowsApi - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - WholphinDispatchers.reset() - } - - @Test - fun `Test nothing is filtered out`() = - runTest { - coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse - coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns - buildRemoved() - val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) - Assert.assertEquals(3, result.size) - val seriesIds = result.map { it.data.seriesId } - Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3))) - } - - @Test - fun `Test seriesId1 is filtered out`() = - runTest { - coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse - coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns - buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1)) - val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) - Assert.assertEquals(2, result.size) - val seriesIds = result.map { it.data.seriesId } - Assert.assertTrue(seriesId1 !in seriesIds) - Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3))) - } - - @Test - fun `Test seriesId2 is filtered out`() = - runTest { - coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse - coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns - buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1)) - val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) - Assert.assertEquals(2, result.size) - val seriesIds = result.map { it.data.seriesId } - Assert.assertTrue(seriesId2 !in seriesIds) - Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3))) - } - - @Test - fun `Test seriesId1 and seriesId2 are filtered out`() = - runTest { - coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse - coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns - buildRemoved( - seriesId1 to LocalDateTime.now().minusDays(1), - seriesId2 to LocalDateTime.now().minusDays(1), - ) - val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) - Assert.assertEquals(1, result.size) - val seriesIds = result.map { it.data.seriesId } - Assert.assertTrue(seriesId1 !in seriesIds) - Assert.assertTrue(seriesId2 !in seriesIds) - Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3))) - } - - fun buildRemoved(vararg values: Pair<UUID, LocalDateTime>): DisplayPreferencesDto = - testDisplayPreferencesDto.copy( - customPrefs = - mutableMapOf<String, String?>().apply { - val str = Json.encodeToString(RemovedSeriesIds(values.toMap())) - put(LatestNextUpService.REMOVED_KEY, str) - }, - ) - - private val testUserItemDataDto = - UserItemDataDto( - playbackPositionTicks = 0L, - playCount = 0, - isFavorite = false, - lastPlayedDate = null, - played = false, - key = "", - itemId = UUID.randomUUID(), - ) - - private val mockResponse: Response<BaseItemDtoQueryResult> = - Response<BaseItemDtoQueryResult>( - content = - BaseItemDtoQueryResult( - items = - listOf( - mockk<BaseItemDto>(relaxed = true) { - every { seriesId } returns seriesId1 - every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7)) - }, - mockk<BaseItemDto>(relaxed = true) { - every { seriesId } returns seriesId2 - every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null) - }, - mockk<BaseItemDto>(relaxed = true) { - every { seriesId } returns seriesId3 - every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7)) - }, - ), - totalRecordCount = 3, - startIndex = 0, - ), - status = 200, - headers = mapOf(), - ) -} - -val testDisplayPreferencesDto = - DisplayPreferencesDto( - id = "default", - viewType = null, - sortBy = null, - indexBy = null, - rememberIndexing = false, - primaryImageHeight = 0, - primaryImageWidth = 0, - customPrefs = mapOf(), - scrollDirection = ScrollDirection.VERTICAL, - showBackdrop = false, - rememberSorting = false, - sortOrder = SortOrder.ASCENDING, - showSidebar = false, - client = null, - ) 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 deleted file mode 100644 index e42600c4..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/MediaManagementServiceTest.kt +++ /dev/null @@ -1,184 +0,0 @@ -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.data.model.ServerUserConfig -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.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 = - ServerUserConfig( - id = UUID.randomUUID(), - policy = - mockk<UserPolicy> { - every { enableLiveTvManagement } returns true - }, - ) - - private val userWithoutLiveTv = - ServerUserConfig( - id = UUID.randomUUID(), - 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/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt deleted file mode 100644 index adb5ca33..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt +++ /dev/null @@ -1,426 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) - -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.api.seerr.MoviesApi -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.api.seerr.TvApi -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException -import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails -import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.SeerrItemType -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.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -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.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test -import java.io.IOException -import java.util.UUID - -/** - * Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked - */ -class SeerrRatingsItemsTest { - private val moviesApi = mockk<MoviesApi>() - private val tvApi = mockk<TvApi>() - private val seerrApi = - mockk<SeerrApiClient> { - every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi - every { tvApi } returns this@SeerrRatingsItemsTest.tvApi - } - private val active = MutableStateFlow(true) - private val seerrService = - mockk<SeerrService> { - every { api } returns seerrApi - every { this@mockk.active } returns this@SeerrRatingsItemsTest.active - } - private val userLibraryApi = mockk<UserLibraryApi>() - private val jellyfin = - mockk<ApiClient> { - every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi - } - private val service = SeerrRatingsService(seerrService, jellyfin) - - private val seriesId = UUID.randomUUID() - private val seriesTmdb = 1396 - - private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh")) - private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh") - - private fun item( - type: BaseItemKind, - providerIds: Map<String, String>? = null, - seriesId: UUID? = null, - ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId)) - - private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) { - coEvery { userLibraryApi.getItem(seriesId) } returns - successResponse( - BaseItemDto( - id = seriesId, - type = BaseItemKind.SERIES, - providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"), - ), - ) - } - - @Test - fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() = - runTest { - assertEquals( - RatingsTarget(SeerrItemType.MOVIE, 11), - service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))), - ) - assertEquals( - RatingsTarget(SeerrItemType.TV, 22), - service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))), - ) - coVerify(exactly = 0) { userLibraryApi.getItem(any()) } - } - - @Test - fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() = - runTest { - jellyfinHasSeries() - // The Tmdb id on an episode or season identifies the episode or season, never the series - val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId) - val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId) - - assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) - assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season)) - } - - @Test - fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() = - runTest { - val none = - listOf( - BaseItemKind.BOX_SET, - BaseItemKind.MUSIC_ALBUM, - BaseItemKind.MUSIC_ARTIST, - BaseItemKind.AUDIO, - BaseItemKind.PHOTO, - BaseItemKind.PHOTO_ALBUM, - BaseItemKind.PROGRAM, - BaseItemKind.PLAYLIST, - BaseItemKind.VIDEO, - ) - none.forEach { - val item = item(it, mapOf("Tmdb" to "5"), seriesId) - assertNull(it.name, service.resolveTarget(item)) - assertNull(it.name, service.forItem(item).first()) - assertNull(it.name, service.cachedFor(item)) - } - coVerify(exactly = 0) { userLibraryApi.getItem(any()) } - coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } - coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) } - } - - @Test - fun episodeWithoutASeriesHasNoRatings() = - runTest { - assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null))) - } - - @Test - fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() = - runTest { - jellyfinHasSeries() - val first = item(BaseItemKind.EPISODE, null, seriesId) - val second = item(BaseItemKind.EPISODE, null, seriesId) - - assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first)) - assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second)) - - // every other episode of the series is known without a request - coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } - } - - @Test - fun homeMovieWithoutProviderIdsLooksItUp() = - runTest { - val movie = item(BaseItemKind.MOVIE, null) - coEvery { userLibraryApi.getItem(movie.id) } returns - successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77"))) - - assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie)) - } - - @Test - fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() = - runTest { - jellyfinHasSeries(tmdb = null) - val episode = item(BaseItemKind.EPISODE, null, seriesId) - - assertNull(service.resolveTarget(episode)) - assertNull(service.forItem(episode).first()) - coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } - } - - @Test - fun failedLookupIsRetriedLater() = - runTest { - coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down") - val episode = item(BaseItemKind.EPISODE, null, seriesId) - assertNull(service.resolveTarget(episode)) - - jellyfinHasSeries() - assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) - } - - @Test - fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() = - runTest { - jellyfinHasSeries() - coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings - val episode = item(BaseItemKind.EPISODE, null, seriesId) - val other = item(BaseItemKind.EPISODE, null, seriesId) - assertNull(service.cachedFor(episode)) - - val ratings = service.forItem(episode).first() - - assertEquals(80, ratings?.rtCritics?.percent) - // moving to another episode of the same series needs no wait and no request - assertEquals(ratings, service.cachedFor(other)) - assertEquals(ratings, service.forItem(other).first()) - coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } - } - - @Test - fun scrollingPastItemsNeverAsksSeerr() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly - val jobs = - (1..20).map { id -> - val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } } - runCurrent() - // focus moves on before the wait has passed - advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) - job.cancel() - job - } - jobs.forEach(Job::cancel) - runCurrent() - - coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } - } - - @Test - fun restingOnAnItemAsksSeerrOnceAfterTheWait() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly - var result: Any? = "unset" - val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } } - - advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1) - runCurrent() - assertEquals("unset", result) - coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } - - advanceTimeBy(2) - runCurrent() - assertNotNull(result) - coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } - job.cancel() - } - - @Test - fun movingOnWhileTheRequestIsRunningCancelsIt() = - runTest { - var cancelled = false - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers { - try { - awaitCancellation() - } finally { - cancelled = true - } - } - val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } } - advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1) - runCurrent() - coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } - - job.cancel() - runCurrent() - - assertEquals(true, cancelled) - assertNull(service.cached(SeerrItemType.MOVIE, 8)) - } - - private fun season(vararg episodes: Triple<Int, Double?, Long?>) = - SeasonDetails( - seasonNumber = 5, - episodes = episodes.map { (number, average, votes) -> SeasonEpisode(episodeNumber = number, voteAverage = average?.toBigDecimal(), voteCount = votes) }, - ) - - private fun episode( - season: Int?, - episode: Int?, - ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.EPISODE, seriesId = seriesId, parentIndexNumber = season, indexNumber = episode)) - - private fun seriesKnown() { - jellyfinHasSeries() - coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings - } - - @Test - fun theSeasonIsFetchedOnceForEveryEpisodeOfIt() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns - season(Triple(1, 8.24, 120L), Triple(2, 7.0, 80L), Triple(3, 9.5, 10L)) - - val first = service.forItem(episode(5, 1)).first()!! - val second = service.forItem(episode(5, 2)).first()!! - val third = service.forItem(episode(5, 3)).first()!! - - // each episode has its own score next to the series' Rotten Tomatoes - assertEquals(listOf(8.24, 7.0, 9.5), listOf(first, second, third).map { it.episodeTmdb }) - assertEquals(80, first.rtCritics?.percent) - coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } - coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } - // and another episode of the season is known without waiting - assertEquals(7.0, service.cachedFor(episode(5, 2))!!.episodeTmdb!!, 0.0) - } - - @Test - fun eachSeasonIsItsOwnRequest() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 1) } returns season(Triple(1, 6.0, 5L)) - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 2) } returns season(Triple(1, 9.0, 5L)) - - assertEquals(6.0, service.forItem(episode(1, 1)).first()!!.episodeTmdb!!, 0.0) - assertEquals(9.0, service.forItem(episode(2, 1)).first()!!.episodeTmdb!!, 0.0) - // the same episode number of another season is not mistaken for it - assertNull(service.cachedFor(episode(3, 1))) - } - - @Test - fun unratedEpisodesShowOnlyTheSeriesRatings() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns - season(Triple(1, 0.0, 0L), Triple(2, 8.0, null), Triple(3, null, null)) - - listOf(1, 2, 3, 4).forEach { number -> - val ratings = service.forItem(episode(5, number)).first()!! - assertNull("episode $number", ratings.episodeTmdb) - assertEquals("episode $number", 80, ratings.rtCritics?.percent) - } - } - - @Test - fun episodeWithoutSeasonOrEpisodeNumberShowsTheSeriesRatingsWithoutAskingForTheSeason() = - runTest { - seriesKnown() - - assertEquals(80, service.forItem(episode(null, 1)).first()!!.rtCritics?.percent) - assertEquals(80, service.forItem(episode(5, null)).first()!!.rtCritics?.percent) - coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } - } - - @Test - fun episodeTmdbReplacesTheSeriesTmdbWhenThatWasTheOnlySource() = - runTest { - jellyfinHasSeries() - coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns TvTvIdRatingsGet200Response() - coEvery { tvApi.tvTvIdGet(tvId = seriesTmdb) } returns - mockk { - every { voteAverage } returns 7.6.toBigDecimal() - every { voteCount } returns 900 - } - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns season(Triple(1, 8.8, 40L)) - - val ratings = service.forItem(episode(5, 1)).first()!! - - assertNull(ratings.tmdb) - assertEquals(8.8, ratings.episodeTmdb!!, 0.0) - } - - @Test - fun failedSeasonRequestKeepsTheSeriesRatingsAndIsRetried() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } throws IOException("down") andThen - season(Triple(1, 8.0, 3L)) - - val first = service.forItem(episode(5, 1)).first()!! - assertNull(first.episodeTmdb) - assertEquals(80, first.rtCritics?.percent) - // not cached as "no score": the screen has to wait again - assertNull(service.cachedFor(episode(5, 1))) - - assertEquals(8.0, service.forItem(episode(5, 1)).first()!!.episodeTmdb!!, 0.0) - } - - @Test - fun seasonTmdbDoesNotKnowIsAnAnswer() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } throws ClientException("none", 404) - - assertNull(service.forItem(episode(9, 1)).first()!!.episodeTmdb) - service.forItem(episode(9, 2)).first() - - coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } - } - - @Test - fun seriesWithoutATmdbIdShowsNothingExtra() = - runTest { - jellyfinHasSeries(tmdb = null) - - assertNull(service.forItem(episode(5, 1)).first()) - coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } - } - - @Test - fun seasonsStillShowOnlyTheirSeriesRatings() = - runTest { - seriesKnown() - val season = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.SEASON, seriesId = seriesId, indexNumber = 5, parentIndexNumber = 5)) - - assertNull(service.forItem(season).first()!!.episodeTmdb) - coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } - } - - @Test - fun scrollingPastEpisodesNeverFetchesASeason() = - runTest { - seriesKnown() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } returns season(Triple(1, 8.0, 3L)) - repeat(10) { number -> - val job = launch { service.forItem(episode(5, number + 1)).collect { } } - runCurrent() - advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) - job.cancel() - } - runCurrent() - - coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } - coVerify(exactly = 0) { userLibraryApi.getItem(any()) } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt deleted file mode 100644 index c6b008ea..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.api.seerr.MoviesApi -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.api.seerr.TvApi -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException -import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer -import com.github.damontecres.wholphin.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb -import com.github.damontecres.wholphin.api.seerr.model.TvDetails -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.RtMark -import com.github.damontecres.wholphin.data.model.SeerrItemType -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.IOException -import java.util.UUID - -class SeerrRatingsServiceTest { - private val moviesApi = mockk<MoviesApi>() - private val tvApi = mockk<TvApi>() - private val apiClient = - mockk<SeerrApiClient> { - every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi - every { tvApi } returns this@SeerrRatingsServiceTest.tvApi - } - private var activeFlow = flowOf(true) - private val seerrService = - mockk<SeerrService> { - every { api } returns apiClient - every { active } answers { activeFlow } - } - private val service = SeerrRatingsService(seerrService, mockk<ApiClient>()) - - private fun movieDetails( - average: Double?, - count: Int?, - ) = mockk<MovieDetails> { - every { voteAverage } returns average?.toBigDecimal() - every { voteCount } returns count - } - - private fun tvDetails( - average: Double?, - count: Int?, - ) = mockk<TvDetails> { - every { voteAverage } returns average?.toBigDecimal() - every { voteCount } returns count - } - - private val fullMovieRatings = - MovieMovieIdRatingscombinedGet200Response( - rt = - MovieMovieIdRatingsGet200Response( - criticsScore = 96, - criticsRating = "Certified Fresh", - audienceScore = 90, - audienceRating = "Upright", - ), - imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000), - ) - - @Test - fun movieUsesRatingsCombinedAndDoesNotRequestTmdbWhenOtherSourcesExist() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 10) } returns fullMovieRatings - - val ratings = service.get(SeerrItemType.MOVIE, 10)!! - - assertEquals(96, ratings.rtCritics?.percent) - assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) - assertEquals(90, ratings.rtAudience?.percent) - assertEquals(8.8, ratings.imdb!!, 0.0) - assertNull(ratings.tmdb) - coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) } - } - - @Test - fun unknownRtLabelsDoNotDropTheResponse() = - runTest { - val json = Serializer.kotlinxSerializationJson - // Labels Seerr might add later must not fail parsing; the scores still show - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 30) } returns - json.decodeFromString<MovieMovieIdRatingscombinedGet200Response>( - """{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" + - """"imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""", - ) - coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns - json.decodeFromString<TvTvIdRatingsGet200Response>( - """{"criticsScore":70,"criticsRating":"Brand New","audienceScore":60,"audienceRating":"Other"}""", - ) - - val movie = service.get(SeerrItemType.MOVIE, 30)!! - assertEquals(92, movie.rtCritics?.percent) - assertEquals(RtMark.UNKNOWN, movie.rtCritics?.mark) - assertEquals(RtMark.UPRIGHT, movie.rtAudience?.mark) - assertEquals(8.8, movie.imdb!!, 0.0) - - val tv = service.get(SeerrItemType.TV, 31)!! - assertEquals(70, tv.rtCritics?.percent) - assertEquals(RtMark.UNKNOWN, tv.rtCritics?.mark) - assertEquals(60, tv.rtAudience?.percent) - } - - @Test - fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() = - runTest { - coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns - TvTvIdRatingsGet200Response( - criticsScore = 70, - criticsRating = "Fresh", - audienceScore = 30, - audienceRating = "Spilled", - ) - - val ratings = service.get(SeerrItemType.TV, 20)!! - - assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) - assertEquals(RtMark.SPILLED, ratings.rtAudience?.mark) - assertNull(ratings.imdb) - } - - @Test - fun tmdbIsFetchedOnlyAsFallbackAndZeroVotesAreUnrated() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 1) } returns - MovieMovieIdRatingscombinedGet200Response() - coEvery { moviesApi.movieMovieIdGet(movieId = 1) } returns movieDetails(7.4, 321) - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 2) } returns - MovieMovieIdRatingscombinedGet200Response() - coEvery { moviesApi.movieMovieIdGet(movieId = 2) } returns movieDetails(0.0, 0) - - assertEquals(7.4, service.get(SeerrItemType.MOVIE, 1)!!.tmdb!!, 0.001) - assertTrue(service.get(SeerrItemType.MOVIE, 2)!!.isEmpty) - } - - @Test - fun knownTmdbVoteAvoidsExtraRequest() = - runTest { - coEvery { tvApi.tvTvIdRatingsGet(tvId = 3) } returns TvTvIdRatingsGet200Response() - - val ratings = service.get(SeerrItemType.TV, 3, TmdbVote(8.1, 900))!! - - assertEquals(8.1, ratings.tmdb!!, 0.0) - coVerify(exactly = 0) { tvApi.tvTvIdGet(any()) } - } - - @Test - fun movieAndSeriesWithTheSameTmdbIdDoNotShareCachedRatings() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings - coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns - TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = "Rotten") - - val movie = service.get(SeerrItemType.MOVIE, 603)!! - val tv = service.get(SeerrItemType.TV, 603)!! - - assertEquals(96, movie.rtCritics?.percent) - assertEquals(12, tv.rtCritics?.percent) - assertEquals(RtMark.ROTTEN, tv.rtCritics?.mark) - assertEquals(movie, service.cached(SeerrItemType.MOVIE, 603)) - assertEquals(tv, service.cached(SeerrItemType.TV, 603)) - } - - @Test - fun successIsCachedSoSeerrIsAskedOnce() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns fullMovieRatings - - service.get(SeerrItemType.MOVIE, 5) - service.get(SeerrItemType.MOVIE, 5) - - coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } - } - - @Test - fun failuresAreNotCachedAndAreRetried() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } throws IOException("down") andThen fullMovieRatings - - assertNull(service.get(SeerrItemType.MOVIE, 7)) - assertNull(service.cached(SeerrItemType.MOVIE, 7)) - assertNotNull(service.get(SeerrItemType.MOVIE, 7)?.rtCritics) - } - - @Test - fun serverErrorIsAFailureButNotFoundIsAnAnswer() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } throws ClientException("boom", 500) - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 9) } throws ClientException("none", 404) - coEvery { moviesApi.movieMovieIdGet(movieId = 9) } returns movieDetails(6.0, 40) - - assertNull(service.get(SeerrItemType.MOVIE, 8)) - assertNull(service.cached(SeerrItemType.MOVIE, 8)) - - // 404 from ratings means no RT/IMDb data, TMDB is still available - assertEquals(6.0, service.get(SeerrItemType.MOVIE, 9)!!.tmdb!!, 0.0) - assertNotNull(service.cached(SeerrItemType.MOVIE, 9)) - } - - @Test - fun failedTmdbFallbackIsNotCachedAsNoRating() = - runTest { - coEvery { tvApi.tvTvIdRatingsGet(tvId = 11) } returns TvTvIdRatingsGet200Response() - coEvery { tvApi.tvTvIdGet(tvId = 11) } throws IOException("down") andThen tvDetails(7.0, 10) - - assertNull(service.get(SeerrItemType.TV, 11)) - assertEquals(7.0, service.get(SeerrItemType.TV, 11)!!.tmdb!!, 0.0) - } - - private fun jellyfinItem( - type: BaseItemKind, - providerIds: Map<String, String>?, - ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds)) - - @Test - fun jellyfinItemResolvesRatingsThroughItsTmdbId() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings - - val item = jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42")) - assertNull(service.cachedFor(item)) - - val ratings = service.forItem(item).first() - - assertEquals(96, ratings?.rtCritics?.percent) - // a later screen can start from the cached value instead of the Jellyfin values - assertEquals(ratings, service.cachedFor(item)) - assertNull(service.cachedFor(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "42")))) - } - - @Test - fun jellyfinItemFallsBackToNullWhenSeerrCannotAnswer() = - runTest { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns fullMovieRatings - coEvery { tvApi.tvTvIdRatingsGet(any()) } throws IOException("down") - - // no TMDB id - assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first()) - assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first()) - // not something that has ratings - assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first()) - // call fails - assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first()) - // Seerr not configured - activeFlow = flowOf(false) - assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first()) - coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } - } -} 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 deleted file mode 100644 index 2c2fdffe..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt +++ /dev/null @@ -1,558 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -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.MostRecentServer -import com.github.damontecres.wholphin.data.MostRecentServerProvider -import com.github.damontecres.wholphin.data.RestoredSession -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.data.model.ServerUserConfig -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.isNotNullOrBlank -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.coVerify -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.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.Assert.assertEquals -import org.junit.Assert.assertTrue -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 mockMostRecentServerProvider = mockk<MostRecentServerProvider>() - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setup() { - WholphinDispatchers.configure(testDispatcher) - - 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 - coEvery { mockJellyfinServerDao.getServer(serverId) } returns - JellyfinServerUsers(server, listOf(user)) - coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs - 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 - coEvery { mockMostRecentServerProvider.save(any()) } just Runs - coEvery { mockMostRecentServerProvider.clear() } just Runs - coEvery { mockMostRecentServerProvider.clearUser() } just Runs - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - WholphinDispatchers.reset() - } - - private fun create() = - ServerRepository( - mockContext, - mockJellyfin, - mockJellyfinServerDao, - mockApiClient, - testDispatcher, - mockMostRecentServerProvider, - ) - - 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 serverUserConfig = ServerUserConfig(userDto) - - 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(serverUserConfig, serverRepository.currentUserDto) - - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } - verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } - } - - @Test - fun `Test restoreUser via changeUser`() = - runTest { - val user = user.copy(pin = null) - 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) - val result = serverRepository.tryChangeUser(server.id, user.id) - assertTrue(result != null) - assertEquals(serverId, result?.server?.id) - assertEquals(user.id, result?.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(serverUserConfig, 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) } - coVerify { mockMostRecentServerProvider.clearUser() } - } - - @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) } - coVerify { mockMostRecentServerProvider.clear() } - } - - @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(serverUserConfig, serverRepository.currentUserDto) - - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } - } - - @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(serverUserConfig, serverRepository.currentUserDto) - - coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } - } - - @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) - } - } - } - - @Test - fun `Test restoreLastSession, success`() = - runTest { - val user = user.copy(pin = null) - coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user - coEvery { mockJellyfinServerDao.getServer(serverId) } returns - JellyfinServerUsers(server, listOf(user)) - every { mockMostRecentServerProvider.get() } returns - MostRecentServer.from(CurrentUser(server, user)) - - val result = create().restoreLastSession() - assertTrue(result is RestoredSession.Success) - val current = (result as RestoredSession.Success).currentUser - assertEquals(server, current.server) - assertEquals(user, current.user) - - verify(exactly = 1) { - mockApiClient.update( - baseUrl = server.url, - accessToken = user.accessToken, - clientInfo = any(), - deviceInfo = any(), - ) - } - coVerify { mockMostRecentServerProvider.save(any()) } - } - - @Test - fun `Test restoreLastSession, user protected`() = - runTest { - every { mockMostRecentServerProvider.get() } returns - MostRecentServer.ServerAndUser(serverId, server.url, userId, "token") - - val result = create().restoreLastSession() - assertTrue(result is RestoredSession.ServerOnly) - val current = (result as RestoredSession.ServerOnly) - assertEquals(server, current.server) - } - - @Test - fun `Test restoreLastSession, server only`() = - runTest { - every { mockMostRecentServerProvider.get() } returns - MostRecentServer.Server(server.id, server.url) - - val result = create().restoreLastSession() - assertTrue(result is RestoredSession.ServerOnly) - val current = (result as RestoredSession.ServerOnly) - assertEquals(server, current.server) - } - - @Test - fun `Test restoreLastSession, nothing to restore`() = - runTest { - every { mockMostRecentServerProvider.get() } returns MostRecentServer.None - - val result = create().restoreLastSession() - assertTrue(result is RestoredSession.None) - } -} - -class TestMostRecentServerProvider : MostRecentServerProvider { - var mostRecentServer: MostRecentServer = MostRecentServer.None - - override fun get(): MostRecentServer = mostRecentServer - - override suspend fun save(current: CurrentUser) { - mostRecentServer = - if (current.user.accessToken.isNotNullOrBlank()) { - MostRecentServer.ServerAndUser( - current.server.id, - current.server.url, - current.user.id, - current.user.accessToken, - ) - } else { - MostRecentServer.Server( - current.server.id, - current.server.url, - ) - } - } - - override suspend fun clear() { - mostRecentServer = MostRecentServer.None - } - - override suspend fun clearUser() { - when (val s = mostRecentServer) { - MostRecentServer.None -> Unit - is MostRecentServer.Server -> Unit - is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl) - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt deleted file mode 100644 index d8304897..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ /dev/null @@ -1,1112 +0,0 @@ -package com.github.damontecres.wholphin.services - -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.ActivationFlag -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.data.model.ServerUserConfig -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.preferences.UserProfileSettings -import io.mockk.every -import io.mockk.mockk -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.SubtitlePlaybackMode -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized - -@RunWith(Parameterized::class) -class StreamChoiceServiceTest( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - TestInput( - null, - SubtitlePlaybackMode.NONE, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - ), - TestInput( - 1, - SubtitlePlaybackMode.NONE, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - plc = plc(subtitleLang = "spa"), - ), - TestInput( - 0, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - ), - TestInput( - 1, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - userSubtitleLang = "spa", - ), - TestInput( - null, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - plc = plc(subtitlesDisabled = true), - ), - TestInput( - null, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED), - ), - TestInput( - 0, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - 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), - ), - ), - ) - } -} - -@RunWith(Parameterized::class) -class TestStreamChoiceServiceDefault( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - ), - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - userSubtitleLang = null, - ), - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", true), - ), - userSubtitleLang = null, - ), - TestInput( - 1, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", true), - ), - userSubtitleLang = null, - ), - TestInput( - null, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - ), - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", false), - ), - ), - TestInput( - 1, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - itemPlayback = itemPlayback(subtitleIndex = 1), - ), - TestInput( - 1, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", false), - ), - plc = plc(subtitleLang = "spa"), - ), - TestInput( - 1, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - plc = plc(subtitleLang = "spa"), - ), - TestInput( - null, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - plc = plc(subtitlesDisabled = true), - ), - ) - } -} - -@RunWith(Parameterized::class) -class TestStreamChoiceServiceSmart( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - TestInput( - 0, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - streamAudioLang = null, - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - ), - TestInput( - 0, - SubtitlePlaybackMode.SMART, - userSubtitleLang = "eng", - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - streamAudioLang = "spa", - ), - TestInput( - 0, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - ), - TestInput( - 1, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - streamAudioLang = "spa", - plc = plc(subtitleLang = "spa"), - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - streamAudioLang = "spa", - plc = plc(subtitlesDisabled = true), - ), - TestInput( - 1, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - userSubtitleLang = "spa", - userAudioLang = "spa", - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", true), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - userSubtitleLang = "spa", - userAudioLang = "eng", - ), - TestInput( - 1, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "spa", false), - ), - streamAudioLang = "eng", - userSubtitleLang = "spa", - userAudioLang = null, - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", false), - subtitle(1, "eng", true), - subtitle(2, "spa", false), - ), - streamAudioLang = "eng", - userSubtitleLang = "eng", - userAudioLang = null, - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng"), - subtitle(1, "spa"), - ), - streamAudioLang = "spa", - userSubtitleLang = "spa", - userAudioLang = null, - ), - ) - } -} - -@RunWith(Parameterized::class) -class TestStreamChoiceServiceOnlyForced( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - TestInput( - null, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - ), - TestInput( - 1, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = 1), - ), - TestInput( - 0, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - plc = plc(subtitleLang = "eng"), - ), - TestInput( - null, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - plc = plc(subtitlesDisabled = true), - ), - TestInput( - 0, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - ), - TestInput( - null, - SubtitlePlaybackMode.ONLY_FORCED, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - ), - TestInput( - 1, - SubtitlePlaybackMode.ONLY_FORCED, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - ), - TestInput( - 0, - SubtitlePlaybackMode.NONE, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - plc = plc(subtitlesOnlyForced = true), - ), - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - plc = plc(subtitlesOnlyForced = true), - ), - TestInput( - 0, - SubtitlePlaybackMode.DEFAULT, - userSubtitleLang = "eng", - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - plc = plc(subtitlesOnlyForced = true), - ), - TestInput( - null, - SubtitlePlaybackMode.NONE, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, "eng", forced = false), - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - plc = plc(subtitlesOnlyForced = true), - ), - ) - } -} - -@RunWith(Parameterized::class) -class TestStreamChoiceServiceMultipleChoices( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - TestInput( - 1, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = true), - subtitle(1, "eng", false), - subtitle(2, "eng", false), - ), - ), - TestInput( - 2, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - ), - TestInput( - 0, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userAudioLang = null, - ), - TestInput( - 2, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = null, - userAudioLang = null, - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = "spa", - userAudioLang = null, - ), - TestInput( - 2, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = true), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = "eng", - userAudioLang = null, - streamAudioLang = "spa", - ), - TestInput( - 0, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = true), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = "eng", - userAudioLang = "eng", - streamAudioLang = "eng", - ), - TestInput( - null, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = "spa", - userAudioLang = "eng", - streamAudioLang = "spa", - ), - TestInput( - 0, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "spa", forced = true, default = false), - subtitle(1, "spa", false), - subtitle(2, "spa", default = true), - ), - userSubtitleLang = "spa", - userAudioLang = "eng", - streamAudioLang = "eng", - ), - TestInput( - 2, - SubtitlePlaybackMode.SMART, - subtitles = - listOf( - subtitle(0, "spa", forced = true, default = false), - subtitle(1, "spa", false), - subtitle(2, "spa", default = true), - ), - userSubtitleLang = "spa", - userAudioLang = "", - streamAudioLang = "eng", - ), - TestInput( - 2, - SubtitlePlaybackMode.DEFAULT, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = false), - subtitle(1, "eng", false), - subtitle(2, "eng", default = true), - ), - userSubtitleLang = null, - userAudioLang = null, - ), - ) - } -} - -/** - * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). - * This tests the findForcedTrack function with user subtitle language preference. - */ -@RunWith(Parameterized::class) -class TestStreamChoiceServiceOnlyForcedClientOverride( - val input: TestInput, -) { - @Test - fun test() { - runTest(input) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{index}: {0}") - fun data(): Collection<TestInput> = - listOf( - // Test 1: Prefer user's subtitle language preference for forced tracks - TestInput( - expectedIndex = 1, // spa forced track (matches user pref) - userSubtitleMode = null, - userSubtitleLang = "spa", - subtitles = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 2: User subtitle preference matches Signs track via title (not forced flag) - TestInput( - expectedIndex = 0, // eng signs track via title detection - userSubtitleMode = null, - userSubtitleLang = "eng", - subtitles = - listOf( - subtitle(0, "eng", forced = false, title = "Signs & Songs"), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "jpn", // different from subtitle pref - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 3: Falls back to audio-matching forced when no preference match - TestInput( - expectedIndex = 0, // eng forced (audio match, step 2) - userSubtitleMode = null, - userSubtitleLang = "spa", // no spa forced exists - subtitles = - listOf( - subtitle(0, "eng", forced = true), // matches audio - subtitle(1, "und", forced = true), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 4: Falls back to audio-matching signs when user pref has no match - TestInput( - expectedIndex = 0, // eng signs (audio match fallback) - userSubtitleMode = null, - userSubtitleLang = "fre", // user prefers French, no French forced exists - subtitles = - listOf( - subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 5: Use audio language for signs/songs when NO subtitle preference - TestInput( - expectedIndex = 0, // eng signs track matching audio - userSubtitleMode = null, - userSubtitleLang = null, // no preference - subtitles = - listOf( - subtitle(0, "eng", forced = false, title = "Signs & Songs"), - subtitle(1, "spa", forced = true), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 6: Unknown language forced track with no preference - TestInput( - expectedIndex = 0, // unknown forced track - userSubtitleMode = null, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, null, forced = true), // unknown/null language - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 7: Unknown language forced track when no audio match - TestInput( - expectedIndex = 0, // unknown forced track (step 3) - userSubtitleMode = null, - userSubtitleLang = null, - subtitles = - listOf( - subtitle(0, "und", forced = true), // unknown language forced - subtitle(1, "spa", forced = false), - ), - streamAudioLang = "eng", // no eng tracks exist - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - // Test 8: No matching forced track returns null (not irrelevant language) - TestInput( - expectedIndex = null, // no forced track matches - return null instead of wrong language - userSubtitleMode = null, - userSubtitleLang = "eng", - subtitles = - listOf( - subtitle(0, "chi", forced = true), // Chinese forced - wrong language - subtitle(1, "spa", forced = true), // Spanish forced - wrong language - ), - streamAudioLang = "eng", // audio is English, no English forced - itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), - ), - ) - } -} - -/** - * 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?, - val userAudioLang: String? = "eng", - val userSubtitleLang: String? = "eng", - val streamAudioLang: String? = "eng", - val subtitles: List<MediaStream>, - val itemPlayback: ItemPlayback? = null, - val plc: List<SeriesTrackChoice> = emptyList(), - 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() }})" -} - -private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" - -fun mockServerRepo( - audioLang: String?, - subtitleMode: SubtitlePlaybackMode?, - subtitleLang: String?, -): ServerRepository { - val mocked = mockk<ServerRepository>() - every { mocked.currentUserDto } returns - ServerUserConfig( - id = UUID.randomUUID(), - configuration = - DefaultUserConfiguration.copy( - audioLanguagePreference = audioLang, - subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, - subtitleLanguagePreference = subtitleLang, - ), - ) - - return mocked -} - -private fun runTest(input: TestInput) { - val service = - StreamChoiceService( - mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk<SeriesTrackChoiceDao>(), - ) - val result = - service.chooseSubtitleStream( - audioStreamLang = input.streamAudioLang, - candidates = input.subtitles, - itemPlayback = input.itemPlayback, - stc = input.plc, - prefs = - UserPreferences( - AppPreferences.getDefaultInstance(), - JellyfinUserPreferences( - preferredAudioLanguage = input.appAudioLang, - preferredSubtitleLanguage = input.appSubtitleLang, - subtitleMode = input.appSubtitleMode, - ), - ), - ) - Assert.assertEquals(input.expectedIndex, result.stream?.index) -} - -fun subtitle( - index: Int, - lang: String?, - default: Boolean = false, - forced: Boolean = false, - sdh: Boolean = false, - external: Boolean = false, - title: String? = null, -): MediaStream = - MediaStream( - type = MediaStreamType.SUBTITLE, - language = lang, - isDefault = default, - isForced = forced, - isHearingImpaired = sdh, - isInterlaced = false, - index = index, - isExternal = external, - isTextSubtitleStream = true, - supportsExternalStream = true, - title = title, - ) - -fun audio( - index: Int, - lang: String?, - default: Boolean = false, - title: String? = null, -): MediaStream = - MediaStream( - type = MediaStreamType.AUDIO, - language = lang, - isDefault = default, - isForced = false, - isHearingImpaired = false, - isInterlaced = false, - index = index, - isExternal = false, - isTextSubtitleStream = true, - supportsExternalStream = true, - title = title, - ) - -private fun itemPlayback( - audioIndex: Int = TrackIndex.UNSPECIFIED, - subtitleIndex: Int = TrackIndex.UNSPECIFIED, -): ItemPlayback = - ItemPlayback( - rowId = 1, - userId = 1, - itemId = UUID.randomUUID(), - sourceId = UUID.randomUUID(), - audioIndex = audioIndex, - subtitleIndex = subtitleIndex, - ) - -private fun plc( - audioLang: String? = null, - subtitleLang: String? = null, - subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, - subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null, -): List<SeriesTrackChoice> = - buildList { - audioLang - ?.let { - SeriesTrackChoice( - userId = 1, - language = audioLang, - parentId = UUID.randomUUID(), - type = SeriesTrackChoiceType.AUDIO, - activation = ActivationFlag.ACTIVATED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ) - }?.let(::add) - - if (subtitleLang != null) { - SeriesTrackChoice( - userId = 1, - language = subtitleLang, - parentId = UUID.randomUUID(), - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ACTIVATED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ).let(::add) - } else if (subtitlesDisabled == true) { - SeriesTrackChoice( - userId = 1, - language = null, - parentId = UUID.randomUUID(), - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ).let(::add) - } else if (subtitlesOnlyForced == true) { - SeriesTrackChoice( - userId = 1, - language = null, - parentId = UUID.randomUUID(), - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ONLY_FORCED, - trackFlags = 0, - codec = null, - relativeTrackIndex = null, - title = null, - audioChannels = null, - ).let(::add) - } - } 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 deleted file mode 100644 index 403a2715..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ /dev/null @@ -1,363 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkInfo -import androidx.work.WorkManager -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import 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.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.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.Response -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.UUID - -@OptIn(ExperimentalCoroutinesApi::class) -class SuggestionServiceTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - private val testDispatcher = StandardTestDispatcher() - - private val mockApi = mockk<ApiClient>(relaxed = true) - private val mockServerRepository = mockk<ServerRepository>() - private val mockCache = mockk<SuggestionsCache>() - private val mockWorkManager = mockk<WorkManager>() - - @Before - fun setUp() { - WholphinDispatchers.configure(testDispatcher) - } - - @After - fun tearDown() { - WholphinDispatchers.reset() - io.mockk.unmockkObject(GetItemsRequestHandler) - } - - private fun createService() = - SuggestionService( - api = mockApi, - serverRepository = mockServerRepository, - cache = mockCache, - workManager = mockWorkManager, - ) - - private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> { - val queryResult = - mockk<BaseItemDtoQueryResult> { - every { this@mockk.items } returns items - } - return mockk { - every { content } returns queryResult - } - } - - private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = - JellyfinUser( - id = id, - name = "TestUser", - serverId = UUID.randomUUID(), - accessToken = "token", - ) - - private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state } - - private fun mockEnqueueOnDemandWork() { - every { - mockWorkManager.enqueueUniqueWork( - any<String>(), - any<ExistingWorkPolicy>(), - any<OneTimeWorkRequest>(), - ) - } returns mockk(relaxed = true) - } - - @Test - fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = - runTest { - val currentUser = MutableStateFlow<JellyfinUser?>(null) - every { mockServerRepository.currentUserFlow } returns currentUser - - val service = createService() - val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - } - - @Test - fun maps_active_onDemand_work_states_to_Loading() = - runTest { - listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns - flowOf(listOf(mockWorkInfo(state))) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Loading, result) - verify { - mockWorkManager.enqueueUniqueWork( - workName, - ExistingWorkPolicy.REPLACE, - any<OneTimeWorkRequest>(), - ) - } - } - } - - @Test - fun maps_finished_work_states_to_Empty() = - runTest { - listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state -> - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns - flowOf(listOf(mockWorkInfo(state))) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - } - } - - @Test - fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - verify { - mockWorkManager.enqueueUniqueWork( - workName, - ExistingWorkPolicy.REPLACE, - any<OneTimeWorkRequest>(), - ) - } - } - - @Test - fun getSuggestionsFlow_returnsSuccess_whenOnDemandWorkCachesItems() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - val cachedId = UUID.randomUUID() - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany - listOf(null, CachedSuggestions(listOf(cachedId))) - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns - flowOf(listOf(mockWorkInfo(WorkInfo.State.SUCCEEDED))) - - val dto = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns cachedId - every { type } returns BaseItemKind.MOVIE - } - io.mockk.mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertTrue(result is SuggestionsResource.Success) - assertEquals(cachedId, (result as SuggestionsResource.Success).items.single().id) - } - - @Test - fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } - verify(exactly = 0) { - mockWorkManager.enqueueUniqueWork( - any<String>(), - any<ExistingWorkPolicy>(), - any<OneTimeWorkRequest>(), - ) - } - } - - @Test - fun getSuggestionsFlow_ignores_periodic_work_whenCacheMissing() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - - every { mockServerRepository.currentUserFlow } returns currentUser - coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns - flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } - verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } - } - - @Test - fun passes_correct_arguments_to_cache() = - runTest { - val userId = UUID.randomUUID() - val libraryId = UUID.randomUUID() - val otherLibraryId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE) - - every { mockServerRepository.currentUserFlow } returns currentUser - mockEnqueueOnDemandWork() - every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) - - coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null - coEvery { - mockCache.get( - userId, - otherLibraryId, - BaseItemKind.MOVIE, - ) - } returns CachedSuggestions(listOf(UUID.randomUUID())) - - val service = createService() - assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) - - coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null - coEvery { - mockCache.get( - userId, - libraryId, - BaseItemKind.SERIES, - ) - } returns CachedSuggestions(listOf(UUID.randomUUID())) - - assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) - } - - @Test - fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - - every { mockServerRepository.currentUserFlow } returns currentUser - - val cachedId = UUID.randomUUID() - coEvery { - mockCache.get( - userId, - parentId, - BaseItemKind.MOVIE, - ) - } returns CachedSuggestions(listOf(cachedId)) - - val dto = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns cachedId - every { type } returns BaseItemKind.MOVIE - } - io.mockk.mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertTrue(result is SuggestionsResource.Success) - val items = (result as SuggestionsResource.Success).items - assertEquals(1, items.size) - assertEquals(cachedId, items[0].id) - } - - @Test - fun getSuggestionsFlow_emitsEmpty_whenApiFails() = - runTest { - val userId = UUID.randomUUID() - val parentId = UUID.randomUUID() - val currentUser = MutableStateFlow<JellyfinUser?>(mockUser(userId)) - - every { mockServerRepository.currentUserFlow } returns currentUser - - val cachedId = UUID.randomUUID() - coEvery { - mockCache.get( - userId, - parentId, - BaseItemKind.MOVIE, - ) - } returns CachedSuggestions(listOf(cachedId)) - - io.mockk.mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") - - val service = createService() - val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() - - assertEquals(SuggestionsResource.Empty, result) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt deleted file mode 100644 index 82e3f2c8..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt +++ /dev/null @@ -1,233 +0,0 @@ -package com.github.damontecres.wholphin.services - -import android.content.Context -import com.mayakapps.kache.ObjectKache -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.model.api.BaseItemKind -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.UUID -import kotlin.io.path.createTempDirectory - -class SuggestionsCacheTest { - private val tempDir = createTempDirectory("suggestions-cache-test").toFile() - - @After - fun tearDown() { - tempDir.deleteRecursively() - } - - private fun testCacheWithTempDir(): SuggestionsCache { - val mockContext = mockk<Context>(relaxed = true) - every { mockContext.cacheDir } returns tempDir - return SuggestionsCache(mockContext) - } - - private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> { - val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") - field.isAccessible = true - @Suppress("UNCHECKED_CAST") - return field.get(cache) as ObjectKache<String, CachedSuggestions> - } - - @Test - fun putThenGet_returnsCachedSuggestions() = - runTest { - val cache = testCacheWithTempDir() - val userId = UUID.randomUUID() - val libId = UUID.randomUUID() - - cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) - - val loaded = cache.get(userId, libId, BaseItemKind.MOVIE) - assertNotNull(loaded) - assertEquals(0, loaded!!.ids.size) - } - - @Test - fun get_readsFromDisk_whenMemoryAbsent() = - runTest { - val cache1 = testCacheWithTempDir() - val userId = UUID.randomUUID() - val libId = UUID.randomUUID() - - cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList()) - - // Create a fresh instance which won't have the memory entry - val cache2 = testCacheWithTempDir() - // memoryCache should be empty - assertTrue(memoryCacheOf(cache2).isEmpty()) - - val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE) - assertNotNull(loaded) - assertEquals(0, loaded!!.ids.size) - // After read, memory cache should contain the entry - assertTrue(memoryCacheOf(cache2).isNotEmpty()) - } - - // LRU behavior is not enforced in production; keep tests focused on public behavior. - @Test - fun memoryCache_respectsLruLimit() = - runTest { - val cache = testCacheWithTempDir() - val userId = UUID.randomUUID() - - // Insert MAX + 2 entries and ensure size never exceeds limit - val limit = 8 // keep in sync with implementation - val libIds = mutableListOf<UUID>() - for (i in 0 until (limit + 2)) { - val libId = UUID.randomUUID() - libIds.add(libId) - cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) - } - - // memoryCache should be bounded to the limit - val mem = memoryCacheOf(cache) - assertTrue(mem.size <= limit) - // The oldest (first inserted) should be evicted from memory cache - val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}" - assertFalse(mem.containsKey(firstKey)) - } - - // Library isolation tests - verify different parentIds/itemKinds don't mix - - @Test - fun differentParentIds_returnIsolatedCacheEntries() = - runTest { - val cache = testCacheWithTempDir() - val userId = UUID.randomUUID() - val movieLibraryId = UUID.randomUUID() - val tvLibraryId = UUID.randomUUID() - - val movieIds = List(2) { UUID.randomUUID() } - val tvIds = List(3) { UUID.randomUUID() } - - cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds) - cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds) - - val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE) - val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE) - - assertNotNull(loadedMovies) - assertNotNull(loadedTv) - assertEquals(2, loadedMovies!!.ids.size) - assertEquals(3, loadedTv!!.ids.size) - assertEquals(movieIds[0], loadedMovies.ids[0]) - assertEquals(tvIds[0], loadedTv.ids[0]) - } - - @Test - fun differentItemKinds_returnIsolatedCacheEntries() = - runTest { - val cache = testCacheWithTempDir() - val userId = UUID.randomUUID() - val libraryId = UUID.randomUUID() - - val movieIds = listOf(UUID.randomUUID()) - val seriesIds = List(2) { UUID.randomUUID() } - - cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds) - cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds) - - val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE) - val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES) - - assertNotNull(loadedMovies) - assertNotNull(loadedSeries) - assertEquals(1, loadedMovies!!.ids.size) - assertEquals(2, loadedSeries!!.ids.size) - assertEquals(movieIds[0], loadedMovies.ids[0]) - assertEquals(seriesIds[0], loadedSeries.ids[0]) - } - - @Test - fun rapidLibrarySwitching_maintainsIsolation() = - runTest { - val cache = testCacheWithTempDir() - val userId = UUID.randomUUID() - val lib1 = UUID.randomUUID() - val lib2 = UUID.randomUUID() - val lib3 = UUID.randomUUID() - - val ids1 = listOf(UUID.randomUUID()) - val ids2 = listOf(UUID.randomUUID()) - val ids3 = listOf(UUID.randomUUID()) - - // Simulate rapid switching: put -> get -> put -> get pattern - cache.put(userId, lib1, BaseItemKind.MOVIE, ids1) - assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - - cache.put(userId, lib2, BaseItemKind.MOVIE, ids2) - assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - - // Switch back to lib1 - should still have correct data - assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - - cache.put(userId, lib3, BaseItemKind.MOVIE, ids3) - assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - - // Verify all libraries still have correct data after rapid switching - assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) - } - - @Test - fun libraryIsolation_persistsToDisk() = - runTest { - val userId = UUID.randomUUID() - val lib1 = UUID.randomUUID() - val lib2 = UUID.randomUUID() - - val ids1 = listOf(UUID.randomUUID()) - val ids2 = listOf(UUID.randomUUID()) - - // Write with first cache instance - val cache1 = testCacheWithTempDir() - cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1) - cache1.put(userId, lib2, BaseItemKind.SERIES, ids2) - - // Read with fresh cache instance (empty memory cache, reads from disk) - val cache2 = testCacheWithTempDir() - assertTrue(memoryCacheOf(cache2).isEmpty()) - - val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE) - val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES) - - assertNotNull(loaded1) - assertNotNull(loaded2) - assertEquals(ids1[0], loaded1!!.ids[0]) - assertEquals(ids2[0], loaded2!!.ids[0]) - } - - @Test - fun differentUsers_returnIsolatedCacheEntries() = - runTest { - val cache = testCacheWithTempDir() - val user1 = UUID.randomUUID() - val user2 = UUID.randomUUID() - val libraryId = UUID.randomUUID() - - val user1Ids = listOf(UUID.randomUUID()) - val user2Ids = List(2) { UUID.randomUUID() } - - cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids) - cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids) - - val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE) - val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE) - - assertNotNull(loadedUser1) - assertNotNull(loadedUser2) - assertEquals(1, loadedUser1!!.ids.size) - assertEquals(2, loadedUser2!!.ids.size) - assertEquals(user1Ids[0], loadedUser1.ids[0]) - assertEquals(user2Ids[0], loadedUser2.ids[0]) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt deleted file mode 100644 index f66e7292..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.appcompat.app.AppCompatActivity -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.work.PeriodicWorkRequest -import androidx.work.WorkManager -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.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.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.util.UUID - -@OptIn(ExperimentalCoroutinesApi::class) -class SuggestionsSchedulerServiceTest { - @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() - private val currentUser = MutableStateFlow<CurrentUser?>(null) - private val mockActivity = mockk<AppCompatActivity>(relaxed = true) - private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockWorkManager = mockk<WorkManager>(relaxed = true) - private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) - - @Before - fun setUp() { - WholphinDispatchers.configure(testDispatcher) - every { mockActivity.lifecycle } returns lifecycleRegistry - every { mockServerRepository.current } returns currentUser - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - } - - @After - fun tearDown() = WholphinDispatchers.reset() - - private fun createService() = - SuggestionsSchedulerService( - context = mockActivity, - serverRepository = mockServerRepository, - workManager = mockWorkManager, - dispatcher = testDispatcher, - ).also { - it.initialDelaySecondsProvider = { 60L } - } - - private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { - @Suppress("UNCHECKED_CAST") - val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() - every { future.get() } returns infos - every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future - } - - @Test - fun schedules_periodic_work_when_user_present() = - runTest { - mockWorkInfos(emptyList()) - createService() - currentUser.value = - CurrentUser( - user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), - server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), - ) - advanceUntilIdle() - verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - } - - @Test - fun cancels_work_when_user_null() = - runTest { - mockWorkInfos(emptyList()) - createService() - currentUser.value = - CurrentUser( - user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), - server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), - ) - advanceUntilIdle() - currentUser.value = null - advanceUntilIdle() - verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } - } - - @Test - fun schedules_periodic_work_with_delay_when_cache_empty() = - runTest { - mockWorkInfos(emptyList()) - val workRequestSlot = slot<PeriodicWorkRequest>() - every { - mockWorkManager.enqueueUniquePeriodicWork( - SuggestionsWorker.WORK_NAME, - any(), - capture(workRequestSlot), - ) - } returns mockk() - - createService() - currentUser.value = - CurrentUser( - user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), - server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), - ) - advanceUntilIdle() - - verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) - } - - @Test - fun schedules_periodic_work_with_delay_when_cache_not_empty() = - runTest { - mockWorkInfos(emptyList()) - val workRequestSlot = slot<PeriodicWorkRequest>() - every { - mockWorkManager.enqueueUniquePeriodicWork( - SuggestionsWorker.WORK_NAME, - any(), - capture(workRequestSlot), - ) - } returns mockk() - - createService() - currentUser.value = - CurrentUser( - user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), - server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), - ) - advanceUntilIdle() - - verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) - } - - @Test - fun does_not_schedule_if_already_scheduled_for_same_user() = - runTest { - val userId = UUID.randomUUID() - val workInfo = mockk<androidx.work.WorkInfo>() - every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED - every { workInfo.tags } returns setOf("user:$userId") - mockWorkInfos(listOf(workInfo)) - - createService() - currentUser.value = - CurrentUser( - user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), - server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), - ) - advanceUntilIdle() - - verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt deleted file mode 100644 index 0e5e0eba..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ /dev/null @@ -1,307 +0,0 @@ -package com.github.damontecres.wholphin.services - -import androidx.datastore.core.DataStore -import androidx.work.Data -import androidx.work.ListenableWorker -import androidx.work.WorkerParameters -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.RestoredSession -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.test.currentUser -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkObject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.exception.ApiClientException -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.operations.UserViewsApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import java.util.UUID - -class SuggestionsWorkerTest { - private val testUserId = UUID.randomUUID() - private val testServerId = UUID.randomUUID() - private val mockWorkerParams = mockk<WorkerParameters>(relaxed = true) - private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockPreferences = mockk<DataStore<AppPreferences>>(relaxed = true) - private val mockApi = mockk<ApiClient>(relaxed = true) - private val mockCache = mockk<SuggestionsCache>(relaxed = true) - private val mockUserViewsApi = mockk<UserViewsApi>(relaxed = true) - - @Before - fun setUp() { - every { mockApi.userViewsApi } returns mockUserViewsApi - every { mockApi.baseUrl } returns "http://localhost" - every { mockApi.accessToken } returns "test-token" - coEvery { mockCache.get(any(), any(), any()) } returns null - } - - @After - fun tearDown() = unmockkObject(GetItemsRequestHandler) - - private fun createWorker( - userId: UUID? = testUserId, - serverId: UUID? = testServerId, - parentId: UUID? = null, - itemKind: BaseItemKind? = null, - ): SuggestionsWorker { - val inputData = - Data - .Builder() - .apply { - userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } - serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } - parentId?.let { putString(SuggestionsWorker.PARAM_PARENT_ID, it.toString()) } - itemKind?.let { putString(SuggestionsWorker.PARAM_ITEM_KIND, it.serialName) } - }.build() - every { mockWorkerParams.inputData } returns inputData - return SuggestionsWorker( - context = mockk(relaxed = true), - workerParams = mockWorkerParams, - serverRepository = mockServerRepository, - preferences = mockPreferences, - api = mockApi, - cache = mockCache, - ) - } - - private fun mockPrefs() = - mockk<AppPreferences>(relaxed = true) { - every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } - } - - @Test - fun returns_failure_on_invalid_input() = - runTest { - listOf( - createWorker(userId = null), - createWorker(serverId = null), - ).forEach { worker -> - assertEquals(ListenableWorker.Result.failure(), worker.doWork()) - } - } - - @Test - fun returns_failure_on_partial_onDemand_input() = - runTest { - listOf( - createWorker(parentId = UUID.randomUUID()), - createWorker(itemKind = BaseItemKind.MOVIE), - ).forEach { worker -> - assertEquals(ListenableWorker.Result.failure(), worker.doWork()) - } - } - - @Test - fun restores_session_when_api_not_configured() = - runTest { - every { mockApi.baseUrl } returns null - every { mockApi.accessToken } returns null - val mockUser = mockk<CurrentUser>() - var restored = false - every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } - coEvery { mockServerRepository.restoreLastSession() } coAnswers { - restored = true - RestoredSession.Success(currentUser()) - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() - - val result = createWorker().doWork() - - coVerify { mockServerRepository.restoreLastSession() } - assertEquals(ListenableWorker.Result.success(), result) - } - - @Test - fun onDemand_work_caches_only_requested_library() = - runTest { - val parentId = UUID.randomUUID() - every { mockPreferences.data } returns flowOf(mockPrefs()) - mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) - - val result = createWorker(parentId = parentId, itemKind = BaseItemKind.MOVIE).doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify(exactly = 0) { mockUserViewsApi.getUserViews(userId = any()) } - coVerify { mockCache.put(testUserId, parentId, BaseItemKind.MOVIE, emptyList()) } - } - - @Test - fun caches_suggestions_for_supported_types() = - runTest { - listOf( - CollectionType.MOVIES to BaseItemKind.MOVIE, - CollectionType.TVSHOWS to BaseItemKind.SERIES, - ).forEach { (collectionType, itemKind) -> - val viewId = UUID.randomUUID() - val view = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns viewId - every { this@mockk.collectionType } returns collectionType - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) - mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true))) - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } - } - } - - @Test - fun caches_empty_suggestions_for_supported_types() = - runTest { - val viewId = UUID.randomUUID() - val view = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns viewId - every { this@mockk.collectionType } returns CollectionType.MOVIES - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) - mockkObject(GetItemsRequestHandler) - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(emptyList()) - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, emptyList()) } - } - - @Test - fun skips_unsupported_collection_types() = - runTest { - val view = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns UUID.randomUUID() - every { this@mockk.collectionType } returns CollectionType.MUSIC - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify(exactly = 0) { mockCache.put(any(), any(), any(), any()) } - } - - @Test - fun fetches_contextual_suggestions_when_genres_available() = - runTest { - val viewId = UUID.randomUUID() - val view = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns viewId - every { this@mockk.collectionType } returns CollectionType.MOVIES - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) - mockkObject(GetItemsRequestHandler) - - val genreId = UUID.randomUUID() - val historyItem = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns UUID.randomUUID() - every { genreItems } returns listOf(mockk { every { id } returns genreId }) - } - val contextualItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } - val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } - val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } - - var callCount = 0 - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { - callCount++ - when (callCount) { - 1 -> mockQueryResult(listOf(historyItem)) - 2 -> mockQueryResult(listOf(contextualItem)) - 3 -> mockQueryResult(listOf(randomItem)) - 4 -> mockQueryResult(listOf(freshItem)) - else -> mockQueryResult(emptyList()) - } - } - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } - } - - @Test - fun skips_contextual_suggestions_when_no_genres_available() = - runTest { - val viewId = UUID.randomUUID() - val view = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns viewId - every { this@mockk.collectionType } returns CollectionType.MOVIES - } - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) - mockkObject(GetItemsRequestHandler) - - val historyItem = - mockk<BaseItemDto>(relaxed = true) { - every { id } returns UUID.randomUUID() - every { genreItems } returns emptyList() - } - val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } - val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } - - var callCount = 0 - coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { - callCount++ - when (callCount) { - 1 -> mockQueryResult(listOf(historyItem)) - 2 -> mockQueryResult(listOf(randomItem)) - 3 -> mockQueryResult(listOf(freshItem)) - else -> mockQueryResult(emptyList()) - } - } - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.success(), result) - coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } - } - - @Test - fun returns_retry_on_network_error() = - runTest { - every { mockPreferences.data } returns flowOf(mockPrefs()) - coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error") - - val result = createWorker().doWork() - - assertEquals(ListenableWorker.Result.retry(), result) - } -} - -fun mockQueryResult(items: List<BaseItemDto> = emptyList()): org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult> { - val queryResult = - mockk<BaseItemDtoQueryResult> { - every { this@mockk.items } returns items - } - return mockk(relaxed = true) { - every { content } returns queryResult - } -} 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 deleted file mode 100644 index 4ef8ab8e..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/BaseItemDestinationTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -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/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt deleted file mode 100644 index b0a8810f..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString -import org.junit.Assert -import org.junit.Test - -class FormattingTests { - @Test - fun testResolutionStrings() { - Assert.assertEquals("4K", resolutionString(3840, 2160, false)) - Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) - Assert.assertEquals("720p", resolutionString(1280, 720, false)) - Assert.assertEquals("480i", resolutionString(640, 480, true)) - - Assert.assertEquals("576p", resolutionString(1024, 576, false)) - Assert.assertEquals("576p", resolutionString(960, 576, false)) - - // 21:9 - Assert.assertEquals("1080p", resolutionString(1920, 822, false)) - - Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt deleted file mode 100644 index c5c2ffee..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.github.damontecres.wholphin.test - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.updateHomePagePreferences -import com.github.damontecres.wholphin.services.DatePlayedService -import com.github.damontecres.wholphin.services.DisplayPreferencesService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.LatestNextUpService -import com.github.damontecres.wholphin.services.mockQueryResult -import com.github.damontecres.wholphin.services.testDisplayPreferencesDto -import io.mockk.CapturingSlot -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.operations.TvShowsApi -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.junit.Assert -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import java.time.LocalDate - -class NextUpTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - private val testDispatcher = StandardTestDispatcher() - - private val mockTvShowsApi = mockk<TvShowsApi>() - private val mockApi = mockk<ApiClient>(relaxed = true) - private val mockDatePlayedService = mockk<DatePlayedService>() - private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>() - private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true) - - private val latestNextUpService = - LatestNextUpService( - mockApi, - mockDatePlayedService, - mockDisplayPreferencesService, - mockFavoriteWatchManager, - ) - - @Before - fun setUp() { - every { mockApi.tvShowsApi } returns mockTvShowsApi - coEvery { - mockDisplayPreferencesService.getDisplayPreferences( - any(), - any(), - any(), - ) - } returns testDisplayPreferencesDto - } - - @Test - fun `Test max 30 days in next up`() = - runTest { - val maxDays = 30 - val nextUpSlot = CapturingSlot<GetNextUpRequest>() - coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() - latestNextUpService.getNextUp( - userId = UUID.randomUUID(), - limit = 10, - enableRewatching = true, - enableResumable = true, - maxDays = maxDays, - ) - Assert.assertEquals(10, nextUpSlot.captured.limit) - val expected = LocalDate.now().minusDays(maxDays.toLong()) - Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate()) - } - - @Test - fun `Test no limit in next up`() = - runTest { - val nextUpSlot = CapturingSlot<GetNextUpRequest>() - coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() - latestNextUpService.getNextUp( - userId = UUID.randomUUID(), - limit = 10, - enableRewatching = true, - enableResumable = true, - maxDays = -1, - ) - Assert.assertEquals(10, nextUpSlot.captured.limit) - Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff) - } - - @Test - fun `Test storing preference`() { - AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let { - Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp) - } - - AppPreference.MaxDaysNextUp.setter - .invoke( - AppPreferences.getDefaultInstance(), - AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(), - ).let { - Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp) - } - - AppPreference.MaxDaysNextUp.setter - .invoke(AppPreferences.getDefaultInstance(), 3) - .let { - Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp) - } - - AppPreference.MaxDaysNextUp.setter - .invoke( - AppPreferences.getDefaultInstance(), - AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, - ).let { - Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp) - } - } - - @Test - fun `Test getting preference`() { - AppPreferences - .getDefaultInstance() - .updateHomePagePreferences { maxDaysNextUp = 7 } - .let { - val result = AppPreference.MaxDaysNextUp.getter.invoke(it) - Assert.assertEquals(0, result) - } - - AppPreferences - .getDefaultInstance() - .updateHomePagePreferences { maxDaysNextUp = 60 } - .let { - val result = AppPreference.MaxDaysNextUp.getter.invoke(it) - Assert.assertEquals(3, result) - } - - AppPreferences - .getDefaultInstance() - .updateHomePagePreferences { maxDaysNextUp = -1 } - .let { - val result = AppPreference.MaxDaysNextUp.getter.invoke(it) - Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result) - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt deleted file mode 100644 index 9aa51656..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.damontecres.wholphin.test - -import androidx.activity.ComponentActivity -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt deleted file mode 100644 index d7e4abfd..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ /dev/null @@ -1,272 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.services.DisplayMode -import com.github.damontecres.wholphin.services.RefreshRateService -import org.junit.Assert -import org.junit.Test - -class TestDisplayModeChoice { - companion object { - val HD_60 = DisplayMode(0, 1920, 1080, 60f) - val HD_30 = DisplayMode(1, 1920, 1080, 30f) - val HD_24 = DisplayMode(2, 1920, 1080, 24f) - - val UHD_60 = DisplayMode(3, 3840, 2160, 60f) - val UHD_30 = DisplayMode(4, 3840, 2160, 30f) - val UHD_24 = DisplayMode(5, 3840, 2160, 24f) - - val HD720_60 = DisplayMode(6, 1280, 720, 60f) - val HD720_30 = DisplayMode(7, 1280, 720, 30f) - val HD720_24 = DisplayMode(8, 1280, 720, 24f) - - val ALL_MODES = - listOf( - UHD_24, - UHD_30, - UHD_60, - HD_24, - HD_30, - HD_60, - HD720_60, - HD720_30, - HD720_24, - ).sortedWith( - compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - } - - @Test - fun test1() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 60f - val result = - RefreshRateService.findDisplayMode( - displayModes = ALL_MODES, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = false, - ) - Assert.assertEquals(UHD_60.modeId, result?.modeId) - } - - @Test - fun test2() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 60f - val result = - RefreshRateService.findDisplayMode( - displayModes = ALL_MODES, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = true, - ) - Assert.assertEquals(HD_60.modeId, result?.modeId) - } - - @Test - fun test3() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 30f - val result = - RefreshRateService.findDisplayMode( - displayModes = ALL_MODES, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = false, - ) - Assert.assertEquals(UHD_30.modeId, result?.modeId) - } - - @Test - fun test4() { - val streamWidth = 1920 - val streamHeight = 804 - val streamRealFrameRate = 30f - val result = - RefreshRateService.findDisplayMode( - displayModes = ALL_MODES, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = false, - resolutionSwitch = true, - ) - Assert.assertEquals(HD_30.modeId, result?.modeId) - } - - @Test - fun testFraction() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 29.970f - - val displayModes = - listOf( - DisplayMode(0, 1920, 1080, 59.940f), - DisplayMode(1, 1920, 1080, 60f), -// DisplayMode(2, 1920, 1080, 29.970f), - ) - - val result = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = false, - ) - Assert.assertEquals(0, result?.modeId) - - val result2 = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = 24f, - refreshRateSwitch = true, - resolutionSwitch = false, - ) - Assert.assertEquals(HD_30.modeId, result2?.modeId) - } - - private fun test( - expected: DisplayMode, - streamWidth: Int, - streamHeight: Int, - streamRealFrameRate: Float, - ) { - val result = - RefreshRateService.findDisplayMode( - displayModes = ALL_MODES, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = false, - resolutionSwitch = true, - ) - Assert.assertEquals( - "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", - expected.modeId, - result?.modeId, - ) - } - - @Test - fun `Test choose best resolution`() { - test(HD720_30, 1280, 720, 30f) - test(HD720_30, 1280, 548, 30f) - test(HD720_30, 640, 480, 30f) - test(HD720_30, 960, 720, 30f) - test(HD720_24, 960, 720, 24f) - } - - @Test - fun `Test 60fps for 24 without resolution switch`() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 24f - - val displayModes = - listOf( - UHD_60, - HD_60, - HD_30, - ) - - val result = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = false, - ) - Assert.assertEquals(UHD_60.modeId, result?.modeId) - } - - @Test - fun `Test 60fps for 24 with resolution switch`() { - val streamWidth = 1920 - val streamHeight = 1080 - val streamRealFrameRate = 24f - - val displayModes = - listOf( - HD_60, - HD_30, - ) - - val result = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = true, - ) - Assert.assertEquals(HD_60.modeId, result?.modeId) - } - - @Test - fun `Test prefer refresh rate over resolution`() { - val streamWidth = 1280 - val streamHeight = 720 - val streamRealFrameRate = 24f - - // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate - val displayModes = - listOf( - HD_24, - HD720_60, - ) - - val result = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = true, - ) - Assert.assertEquals(HD_24.modeId, result?.modeId) - } - - @Test - fun `Test prefer refresh rate over resolution2`() { - val streamWidth = 1280 - val streamHeight = 720 - val streamRealFrameRate = 30f - - // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate - val displayModes = - listOf( - HD_30, - HD720_60, - ) - - val result = - RefreshRateService.findDisplayMode( - displayModes = displayModes, - streamWidth = streamWidth, - streamHeight = streamHeight, - targetFrameRate = streamRealFrameRate, - refreshRateSwitch = true, - resolutionSwitch = true, - ) - Assert.assertEquals(HD_30.modeId, result?.modeId) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt deleted file mode 100644 index 6a801c16..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestFindIndexByNumberOrId.kt +++ /dev/null @@ -1,222 +0,0 @@ -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/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt deleted file mode 100644 index e38479f0..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.HdrOverrideMode -import com.github.damontecres.wholphin.preferences.PlaybackOverrides -import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor -import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides -import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType -import org.junit.Assert -import org.junit.Test - -class TestHdrFormat { - private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences = - AppPreferences.getDefaultInstance().updatePlaybackOverrides(block) - - @Test - fun `Test all auto returns empty sets`() { - val prefs = appPreferences { } - Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true)) - Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) - } - - @Test - fun `Test HDR10 override`() { - val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } - Assert.assertEquals( - setOf(WholphinVideoRangeType.HDR10.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), - ) - Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) - } - - @Test - fun `Test Dolby Vision Profile 5 override`() { - val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } - Assert.assertEquals( - setOf(WholphinVideoRangeType.DOVI.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), - ) - } - - @Test - fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() { - val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } - Assert.assertEquals( - emptySet<String>(), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false), - ) - Assert.assertEquals( - setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), - ) - } - - @Test - fun `Test Dolby Vision Profile 8 override`() { - val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } - Assert.assertEquals( - setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false), - ) - Assert.assertEquals( - setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), - ) - } - - @Test - fun `Test multiple formats set to the same mode are unioned`() { - val prefs = - appPreferences { - hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE - hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE - doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE - } - Assert.assertEquals( - setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), - ) - Assert.assertEquals( - setOf(WholphinVideoRangeType.DOVI.serialName), - prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), - ) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt deleted file mode 100644 index a6fe6e2a..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter -import com.github.damontecres.wholphin.data.model.HomeRowConfig -import com.github.damontecres.wholphin.data.model.HomeRowViewOptions -import com.github.damontecres.wholphin.data.model.KeyValueEntry -import com.github.damontecres.wholphin.preferences.PrefContentScale -import com.github.damontecres.wholphin.services.HomeSettingsService -import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.components.ViewOptionImageType -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import io.mockk.mockk -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.junit.Assert -import org.junit.Test -import kotlin.reflect.KClass - -class TestHomeRowSamples { - companion object { - val SAMPLES = - listOf( - HomeRowConfig.RecentlyAdded( - parentId = UUID.randomUUID(), - viewOptions = - HomeRowViewOptions( - heightDp = 100, - spacing = 8, - contentScale = PrefContentScale.CROP, - aspectRatio = AspectRatio.FOUR_THREE, - imageType = ViewOptionImageType.THUMB, - showTitles = false, - useSeries = false, - ), - ), - HomeRowConfig.RecentlyReleased( - parentId = UUID.randomUUID(), - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.Genres( - parentId = UUID.randomUUID(), - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.Studios(parentId = UUID.randomUUID()), - HomeRowConfig.ContinueWatching( - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.NextUp( - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.ContinueWatchingCombined( - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.ByParent( - parentId = UUID.randomUUID(), - recursive = true, - sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING), - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.GetItems( - name = "Episodes by date created", - filter = - HomeGetItemsFilter( - parentId = UUID.randomUUID(), - recursive = true, - isFavorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), - sortBy = listOf(ItemSortBy.DATE_CREATED), - sortOrder = listOf(SortOrder.DESCENDING), - ), - viewOptions = HomeRowViewOptions(), - ), - HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), - HomeRowConfig.Recordings(), - HomeRowConfig.TvPrograms(), - HomeRowConfig.TvChannels(), - HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), - HomeRowConfig.CustomEndpoint( - endpoint = "/path/to/items", - title = "Items", - query = listOf(KeyValueEntry("key", "value")), - headers = listOf(KeyValueEntry("X-Header", "1234")), - ), - ) - } - - @Test - fun `Check all types have a sample`() { - // This ensures there is a sample for each possible HomeRowConfig type - val foundTypes = mutableSetOf<KClass<out HomeRowConfig>>() - SAMPLES.forEach { - when (it) { - is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class) - is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class) - is HomeRowConfig.Genres -> foundTypes.add(it::class) - is HomeRowConfig.NextUp -> foundTypes.add(it::class) - is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class) - is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class) - is HomeRowConfig.ByParent -> foundTypes.add(it::class) - is HomeRowConfig.GetItems -> foundTypes.add(it::class) - is HomeRowConfig.Favorite -> foundTypes.add(it::class) - is HomeRowConfig.Recordings -> foundTypes.add(it::class) - is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) - is HomeRowConfig.Suggestions -> foundTypes.add(it::class) - is HomeRowConfig.TvChannels -> foundTypes.add(it::class) - is HomeRowConfig.Studios -> foundTypes.add(it::class) - is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class) - } - } - Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) - } - - @Test - fun `Print sample JSON`() { - // This just prints out the JSON of the samples so developers can review - val json = - Json { - ignoreUnknownKeys = true - isLenient = true - prettyPrint = true - } - val string = json.encodeToString(SAMPLES) - println(string) - json.decodeFromString<List<HomeRowConfig>>(string) - } - - @Test - fun `Parse list of rows with an unknown type`() { - val service = - HomeSettingsService( - context = mockk(), - api = mockk(), - serverRepository = mockk(), - navDrawerService = mockk(), - displayPreferencesService = mockk(), - serverPluginApi = mockk(), - serverDao = mockk(), - ) - - val str = """{ - "type": "HomePageSettings", - "version": 1, - "rows": [ - { - "type": "RecentlyAdded", - "parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf" - }, - { - "type": "Does not exist", - "viewOptions": {} - } - ] - }""" - - val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str) - val settings = service.decode(jsonElement) - Assert.assertEquals(1, settings.rows.size) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMedia3SubtitleOverride.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestMedia3SubtitleOverride.kt deleted file mode 100644 index ccc48cb3..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestMedia3SubtitleOverride.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.util.Media3SubtitleOverride -import org.junit.Assert -import org.junit.Test - -class TestMedia3SubtitleOverride { - @Test - fun test() { - // This tests whether the class and field names exist - Media3SubtitleOverride(2f) - Assert.assertTrue(Media3SubtitleOverride.Companion.initialized) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt deleted file mode 100644 index c19ffb81..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl -import com.github.damontecres.wholphin.ui.setup.seerr.createUrls -import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl -import org.junit.Assert.assertEquals -import org.junit.Test - -class TestSeerr { - @Test - fun testCreateUrls() { - val urls = - createUrls("jellyseerr.com") - .map { it.toString() } - - val expected = - listOf( - "http://jellyseerr.com/", - "https://jellyseerr.com/", - "http://jellyseerr.com:5055/", - "https://jellyseerr.com:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls2() { - val urls = - createUrls("https://jellyseerr.com") - .map { it.toString() } - - val expected = - listOf( - "https://jellyseerr.com/", - "https://jellyseerr.com:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls3() { - val urls = - createUrls("http://jellyseerr.com") - .map { it.toString() } - - val expected = - listOf( - "http://jellyseerr.com/", - "http://jellyseerr.com:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls4() { - val urls = - createUrls("jellyseerr.com:5055") - .map { it.toString() } - - val expected = - listOf( - "http://jellyseerr.com:5055/", - "https://jellyseerr.com:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls5() { - val urls = - createUrls("10.0.0.2:443") - .map { it.toString() } - - val expected = - listOf( - "http://10.0.0.2:443/", - "https://10.0.0.2/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls6() { - val urls = - createUrls("10.0.0.2:8080") - .map { it.toString() } - - val expected = - listOf( - "http://10.0.0.2:8080/", - "https://10.0.0.2:8080/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls7() { - val urls = - createUrls("http://10.0.0.2:80") - .map { it.toString() } - - val expected = - listOf( - "http://10.0.0.2/", - "http://10.0.0.2:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun testCreateUrls8() { - val urls = - createUrls("https://10.0.0.2:443") - .map { it.toString() } - - val expected = - listOf( - "https://10.0.0.2/", - "https://10.0.0.2:5055/", - ) - assertEquals(expected, urls) - } - - @Test - fun `Test createUrls for path`() { - val urls = - createUrls("https://jellyseerr.com/seerr/") - .map { it.toString() } - - val expected = - listOf( - "https://jellyseerr.com/seerr/", - "https://jellyseerr.com:5055/seerr/", - ) - assertEquals(expected, urls) - } - - @Test - fun `Test build api url`() { - var url = "https://jellyseerr.com/" - assertEquals("https://jellyseerr.com/api/v1", createSeerrApiUrl(url)) - - url = "https://jellyseerr.com/path" - assertEquals("https://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) - - url = "http://jellyseerr.com:5055/" - assertEquals("http://jellyseerr.com:5055/api/v1", createSeerrApiUrl(url)) - - url = "http://jellyseerr.com:7878/path/" - assertEquals("http://jellyseerr.com:7878/path/api/v1", createSeerrApiUrl(url)) - - url = "http://jellyseerr.com/api/v1" - assertEquals("http://jellyseerr.com/api/v1", createSeerrApiUrl(url)) - - url = "http://jellyseerr.com/api/v1/" - assertEquals("http://jellyseerr.com/api/v1/", createSeerrApiUrl(url)) - - url = "http://jellyseerr.com/path/api/v1" - assertEquals("http://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) - } - - @Test - fun `Test migration`() { - assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1")) - assertEquals("https://10.0.0.2/", migrateSeerrUrl("https://10.0.0.2/api/v1")) - assertEquals("http://10.0.0.2:5055/", migrateSeerrUrl("http://10.0.0.2:5055/api/v1")) - assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1/")) - assertEquals("http://10.0.0.2/path/", migrateSeerrUrl("http://10.0.0.2/path/api/v1")) - assertEquals("http://10.0.0.2/api/v1/", migrateSeerrUrl("http://10.0.0.2/api/v1/api/v1")) - assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/")) - assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2")) - } -} 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 deleted file mode 100644 index b5dfa5c3..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerrPermission.kt +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index a98440f4..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSelectionTrackExamples.kt +++ /dev/null @@ -1,356 +0,0 @@ -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/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt deleted file mode 100644 index e58d8aec..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt +++ /dev/null @@ -1,534 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao -import com.github.damontecres.wholphin.data.model.ActivationFlag -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences -import com.github.damontecres.wholphin.data.model.SeriesTrackChoice -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.data.model.TrackChoiceParentType -import com.github.damontecres.wholphin.data.model.TrackFlag -import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.StreamChoiceReason -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.audio -import com.github.damontecres.wholphin.services.mockServerRepo -import com.github.damontecres.wholphin.services.subtitle -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -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.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.SubtitlePlaybackMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -class TestSeriesTrackChoice { - private val mockServerRepository = - mockServerRepo( - audioLang = "eng", - subtitleLang = "eng", - subtitleMode = SubtitlePlaybackMode.DEFAULT, - ) - private val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = false) - - private val streamChoiceService = - StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) - - private val seriesId = UUID.randomUUID() - private val season1Id = UUID.randomUUID() - private val season2Id = UUID.randomUUID() - private val season3Id = UUID.randomUUID() - - private val user = - JellyfinUser( - 1, - UUID.randomUUID(), - null, - UUID.randomUUID(), - null, - ) - - @Before - fun before() { - every { mockServerRepository.currentUser } returns user - } - - @Test - fun `Test StreamChoiceService call correct dao function`() = - runTest { - val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = true) - val streamChoiceService = - StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) - - val seriesId = UUID.randomUUID() - val seasonId = UUID.randomUUID() - - // Has both series & season - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.EPISODE, - seriesId = seriesId, - parentId = seasonId, - ).let { item -> - streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) - coVerify(exactly = 1) { - mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) - } - } - - // Has only series - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.EPISODE, - seriesId = seriesId, - parentId = null, - ).let { item -> - streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) - coVerify(exactly = 1) { - mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE) - } - } - - // Has only season - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.EPISODE, - seriesId = null, - parentId = seasonId, - ).let { item -> - streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) - coVerify(exactly = 1) { - mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) - } - } - } - - @Test - fun `Test series disabled`() = - runTest { - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true), - subtitle(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - ), - ), - ) - assertNull(result.stream) - } - - @Test - fun `Test series only forced`() = - runTest { - val mockServerRepository = - mockServerRepo( - audioLang = "eng", - subtitleLang = "eng", - subtitleMode = SubtitlePlaybackMode.NONE, - ) - val streamChoiceService = - StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) - - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = - UserPreferences( - AppPreferences.getDefaultInstance(), - JellyfinUserPreferences(), - ), - candidates = - listOf( - subtitle(0, "eng", forced = true), - subtitle(1, "eng", forced = false), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ONLY_FORCED, - ), - ), - ) - assertNotNull(result.stream) - assertEquals(0, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Series) - } - - @Test - fun `Test series disabled with season`() = - runTest { - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true), - subtitle(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - ), - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - ), - ), - ) - assertNull(result.stream) - assertTrue(result.reason is StreamChoiceReason.Series) - } - - @Test - fun `Test item playback overrides STC for subtitles`() = - runTest { - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = - ItemPlayback( - userId = user.rowId, - itemId = UUID.randomUUID(), - subtitleIndex = 1, - ), - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true), - subtitle(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - ), - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.DISABLED, - ), - ), - ) - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Item) - } - - @Test - fun `Test item playback overrides STC for audio`() = - runTest { - val result = - streamChoiceService.chooseAudioStream( - itemPlayback = - ItemPlayback( - userId = user.rowId, - itemId = UUID.randomUUID(), - audioIndex = 1, - ), - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - audio(0, "eng", default = true), - audio(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.AUDIO, - language = "eng", - ), - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.AUDIO, - language = "eng", - ), - ), - ) - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Item) - } - - @Test - fun `Test season STC doesn't apply but series STC does - subtitle`() = - runTest { - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = - ItemPlayback( - userId = user.rowId, - itemId = UUID.randomUUID(), - audioIndex = 1, - ), - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true), - subtitle(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ONLY_FORCED, - ), - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ACTIVATED, - language = "spa", - ), - ), - ) - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Series) - } - - @Test - fun `Test season STC doesn't apply but series STC does - audio`() = - runTest { - val result = - streamChoiceService.chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - audio(0, "eng", default = true), - audio(1, "spa"), - ), - stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.AUDIO, - activation = ActivationFlag.ONLY_FORCED, - ), - SeriesTrackChoice( - userId = user.rowId, - parentId = seriesId, - parentType = TrackChoiceParentType.SERIES, - type = SeriesTrackChoiceType.AUDIO, - activation = ActivationFlag.ACTIVATED, - language = "spa", - ), - ), - ) - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Series) - } - - @Test - fun `Test STC has forced flag`() = - runTest { - val stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ACTIVATED, - trackFlags = listOf(TrackFlag.FORCED).calculateFlag(), - ), - ) - - // Choose forced track - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true, forced = true), - subtitle(1, "spa"), - ), - stc = stc, - ).let { result -> - assertNotNull(result.stream) - assertEquals(0, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Season) - } - - // No forced track to choose from, so nothing should be chosen - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = false, forced = false), - subtitle(1, "spa"), - ), - stc = stc, - ).let { result -> - assertNull(result.stream) - } - } - - @Test - fun `Test prefer SDH`() = - runTest { - val stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ACTIVATED, - trackFlags = listOf(TrackFlag.SDH).calculateFlag(), - ), - ) - - // Prefer SDH over default and non-SDH - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true, forced = true), - subtitle(1, "eng", default = false, forced = false, sdh = false), - subtitle(2, "eng", default = false, forced = false, sdh = true), - ), - stc = stc, - ).let { result -> - assertNotNull(result.stream) - assertEquals(2, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Season) - } - - // No SDH, so prefer regular over forced - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true, forced = true), - subtitle(1, "eng", default = false, forced = false, sdh = false), - ), - stc = stc, - ).let { result -> - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Season) - } - } - - @Test - fun `Test prefer default track with no other flags`() = - runTest { - val stc = - listOf( - SeriesTrackChoice( - userId = user.rowId, - parentId = season1Id, - parentType = TrackChoiceParentType.SEASON, - type = SeriesTrackChoiceType.SUBTITLE, - activation = ActivationFlag.ACTIVATED, - trackFlags = 0, - ), - ) - - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = true, forced = false), - subtitle(1, "eng", default = false, forced = false), - ), - stc = stc, - ).let { result -> - assertNotNull(result.stream) - assertEquals(0, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Season) - } - - // Test out-of-order tracks - streamChoiceService - .chooseSubtitleStream( - audioStreamLang = "eng", - itemPlayback = null, - prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), - candidates = - listOf( - subtitle(0, "eng", default = false, forced = false), - subtitle(1, "eng", default = true, forced = false), - ), - stc = stc, - ).let { result -> - assertNotNull(result.stream) - assertEquals(1, result.stream!!.index) - assertTrue(result.reason is StreamChoiceReason.Season) - } - } -} 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 deleted file mode 100644 index fec67c6b..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSubtitleHdrMigration.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 90b45182..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt +++ /dev/null @@ -1,265 +0,0 @@ -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.NameGuidPair -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized - -/** - * Tests for the suggestions deduplication and genre collection logic used in - * RecommendedMovie and RecommendedTvShow ViewModels. - */ -class TestSuggestionsDeduplication { - @Test - fun `deduplication by seriesId groups episodes of same series`() { - val seriesId = UUID.randomUUID() - val items = - listOf( - episode(seriesId = seriesId, name = "Episode 1"), - episode(seriesId = seriesId, name = "Episode 2"), - episode(seriesId = seriesId, name = "Episode 3"), - movie(name = "Some Movie"), - ) - - val deduplicated = - items - .distinctBy { it.seriesId ?: it.id } - .take(3) - - // Should have 2 items: one series entry and one movie - assertEquals(2, deduplicated.size) - } - - @Test - fun `deduplication preserves order - most recent first`() { - val series1 = UUID.randomUUID() - val series2 = UUID.randomUUID() - val items = - listOf( - episode(seriesId = series1, name = "S1 Episode 1"), // Most recent - episode(seriesId = series1, name = "S1 Episode 2"), - episode(seriesId = series2, name = "S2 Episode 1"), - movie(name = "Old Movie"), - ) - - val deduplicated = - items - .distinctBy { it.seriesId ?: it.id } - .take(3) - - assertEquals(3, deduplicated.size) - // First item should be from series1 (most recent) - assertEquals(series1, deduplicated[0].seriesId) - assertEquals(series2, deduplicated[1].seriesId) - } - - @Test - fun `movies use their own id for deduplication`() { - val items = - listOf( - movie(name = "Movie 1"), - movie(name = "Movie 2"), - movie(name = "Movie 3"), - ) - - val deduplicated = - items - .distinctBy { it.seriesId ?: it.id } - .take(3) - - // All movies should be kept since they have unique IDs - assertEquals(3, deduplicated.size) - } - - @Test - fun `take 3 limits seed items`() { - val items = - listOf( - movie(name = "Movie 1"), - movie(name = "Movie 2"), - movie(name = "Movie 3"), - movie(name = "Movie 4"), - movie(name = "Movie 5"), - ) - - val deduplicated = - items - .distinctBy { it.seriesId ?: it.id } - .take(3) - - assertEquals(3, deduplicated.size) - } -} - -class TestSuggestionsGenreCollection { - @Test - fun `collects genres from multiple seed items`() { - val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") - val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") - val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama") - - val seedItems = - listOf( - movie(name = "Action Movie", genres = listOf(genre1)), - movie(name = "Comedy Movie", genres = listOf(genre2)), - movie(name = "Drama Movie", genres = listOf(genre3)), - ) - - val allGenreIds = - seedItems - .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } - .distinct() - - assertEquals(3, allGenreIds.size) - assertTrue(allGenreIds.contains(genre1.id)) - assertTrue(allGenreIds.contains(genre2.id)) - assertTrue(allGenreIds.contains(genre3.id)) - } - - @Test - fun `deduplicates shared genres across seed items`() { - val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action") - val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") - - val seedItems = - listOf( - movie(name = "Action Movie 1", genres = listOf(sharedGenre)), - movie(name = "Action Movie 2", genres = listOf(sharedGenre)), - movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)), - ) - - val allGenreIds = - seedItems - .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } - .distinct() - - // Should only have 2 unique genres - assertEquals(2, allGenreIds.size) - } - - @Test - fun `handles items with no genres`() { - val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") - - val seedItems = - listOf( - movie(name = "Movie with genre", genres = listOf(genre1)), - movie(name = "Movie without genre", genres = null), - movie(name = "Movie with empty genres", genres = emptyList()), - ) - - val allGenreIds = - seedItems - .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } - .distinct() - - assertEquals(1, allGenreIds.size) - assertEquals(genre1.id, allGenreIds[0]) - } - - @Test - fun `returns empty list when no seed items have genres`() { - val seedItems = - listOf( - movie(name = "Movie 1", genres = null), - movie(name = "Movie 2", genres = emptyList()), - ) - - val allGenreIds = - seedItems - .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } - .distinct() - - assertTrue(allGenreIds.isEmpty()) - } -} - -class TestSuggestionsExcludeIds { - @Test - fun `excludeIds contains all seed item ids`() { - val seedItems = - listOf( - movie(name = "Movie 1"), - movie(name = "Movie 2"), - movie(name = "Movie 3"), - ) - - val excludeIds = seedItems.map { it.id } - - assertEquals(3, excludeIds.size) - seedItems.forEach { item -> - assertTrue(excludeIds.contains(item.id)) - } - } -} - -@RunWith(Parameterized::class) -class TestSuggestionsCombineAndDeduplicate( - private val contextual: List<BaseItemDto>, - private val random: List<BaseItemDto>, - private val fresh: List<BaseItemDto>, - private val expectedUniqueCount: Int, - private val description: String, -) { - @Test - fun `combine and deduplicate works correctly`() { - val combined = - (contextual + random + fresh) - .distinctBy { it.id } - - assertEquals(description, expectedUniqueCount, combined.size) - } - - companion object { - @JvmStatic - @Parameterized.Parameters(name = "{4}") - fun data(): Collection<Array<Any>> { - val movie1 = movie(name = "Movie 1") - val movie2 = movie(name = "Movie 2") - val movie3 = movie(name = "Movie 3") - val movie4 = movie(name = "Movie 4") - - return listOf( - arrayOf( - listOf(movie1, movie2), - listOf(movie3), - listOf(movie4), - 4, - "no duplicates - all 4 unique", - ), - arrayOf( - listOf(movie1, movie2), - listOf(movie1, movie3), - listOf(movie2, movie4), - 4, - "with duplicates - deduplicates to 4", - ), - arrayOf( - listOf(movie1), - listOf(movie1), - listOf(movie1), - 1, - "all same - deduplicates to 1", - ), - arrayOf( - emptyList<BaseItemDto>(), - listOf(movie1, movie2), - listOf(movie3), - 3, - "empty contextual - still combines others", - ), - arrayOf( - emptyList<BaseItemDto>(), - emptyList<BaseItemDto>(), - emptyList<BaseItemDto>(), - 0, - "all empty - returns empty", - ), - ) - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt deleted file mode 100644 index d2013f19..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.data.model.TrackFlag -import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.EnumSet - -class TestTrackFlag { - @Test - fun basicTest() { - val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag() - assertTrue(TrackFlag.DEFAULT.within(flag)) - assertTrue(TrackFlag.EXTERNAL.within(flag)) - assertFalse(TrackFlag.FORCED.within(flag)) - assertFalse(TrackFlag.SDH.within(flag)) - } - - @Test - fun none() { - val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag() - assertFalse(TrackFlag.DEFAULT.within(flag)) - assertFalse(TrackFlag.EXTERNAL.within(flag)) - assertFalse(TrackFlag.FORCED.within(flag)) - assertFalse(TrackFlag.SDH.within(flag)) - } - - @Test - fun all() { - val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag() - TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) } - } - - @Test - fun checkFlags() { - TrackFlag.entries.forEach { - assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0) - } - val distinctFlags = TrackFlag.entries.map { it.flag }.distinct() - assertEquals(TrackFlag.entries.size, distinctFlags.size) - } -} 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 deleted file mode 100644 index 41db76aa..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt +++ /dev/null @@ -1,817 +0,0 @@ -package com.github.damontecres.wholphin.test - -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.TrackGroup -import androidx.media3.common.TrackSelectionParameters -import androidx.media3.common.Tracks -import com.github.damontecres.wholphin.data.model.TrackIndex -import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.junit.Assert -import org.junit.Test -import java.nio.file.Paths -import kotlin.io.path.readText - -class TestTrackSelection { - /** - * Builds the tracks for the `embedded_subs.json` for the given backend - * - * Note: This is manual based on observation & code review of the playback for that file - */ - private fun buildEmbeddedTracks(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 { - val video = - Format - .Builder() - .setId("1") - .setSampleMimeType("video/default") - .build() - val audios = - (2..4).map { - Format - .Builder() - .setId("$it") - .setSampleMimeType("audio/default") - .build() - } - val subtitles = - (5..7).map { - Format - .Builder() - .setId("$it") - .setSampleMimeType("text/default") - .build() - } - (listOf(video) + audios + subtitles) - } - val groups = - formats - .map { TrackGroup(it) } - .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } - return Tracks(groups) - } - - /** - * Builds the tracks for the `no_embedded_subs.json` for the given backend - * - * Note: This is manual based on observation & code review of the playback for that file - */ - private fun buildNoEmbeddedTracks(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( - Format - .Builder() - .setId("7:e:4") - .setSampleMimeType("text/default") - .build(), - ) - (listOf(video) + audios + subtitles) - } else { - // ExoPlayer - val video = - Format - .Builder() - .setId("0:1") - .setSampleMimeType("video/default") - .build() - val audios = - (2..4).map { - Format - .Builder() - .setId("0:$it") - .setSampleMimeType("audio/default") - .build() - } - val subtitles = - (5..7).map { - Format - .Builder() - .setId("0:$it") - .setSampleMimeType("text/default") - .build() - } + - listOf( - Format - .Builder() - .setId("1:e:0") - .setSampleMimeType("text/default") - .build(), - ) - (listOf(video) + audios + subtitles) - } - val groups = - formats - .map { TrackGroup(it) } - .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } - return Tracks(groups) - } - - /** - * Builds the tracks for the `external_subs.json` for the given backend. - * - * Must supply the desired subtitle index because ExoPlayer uses it. - * - * Note: This is manual based on observation & code review of the playback for that file - */ - private fun buildExternalTracks( - backend: PlayerBackend, - selectedIndex: Int, - ): Tracks { - val formats = - if (backend == PlayerBackend.MPV) { - val video = - Format - .Builder() - .setId("1:1") - .setSampleMimeType("video/default") - .build() - val audios = - listOf( - Format - .Builder() - .setId("0:1") - .setSampleMimeType("audio/default") - .build(), - ) - val subtitles = - listOf( - Format - .Builder() - .setId("2:1") - .setSampleMimeType("text/default") - .build(), - Format - .Builder() - .setId("3:e:2") - .setSampleMimeType("text/default") - .build(), - ) - (listOf(video) + audios + subtitles) - } else { - val video = - Format - .Builder() - .setId("0:2") - .setSampleMimeType("video/default") - .build() - val audios = - listOf( - Format - .Builder() - .setId("0:1") - .setSampleMimeType("audio/default") - .build(), - ) - val subtitles = - listOf( - Format - .Builder() - .setId("0:3") // Embedded - .setSampleMimeType("text/default") - .build(), - Format - .Builder() - .setId("1:e:$selectedIndex") // External - .setSampleMimeType("text/default") - .build(), - ) - (listOf(video) + audios + subtitles) - } - val groups = - formats - .map { TrackGroup(it) } - .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } - return Tracks(groups) - } - - 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) - } - val groups = - formats - .map { TrackGroup(it) } - .map { - Tracks.Group( - it, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(false), - ) - } - return Tracks(groups) - } - - 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) - } - val groups = - formats - .map { TrackGroup(it) } - .map { - Tracks.Group( - it, - false, - intArrayOf(C.FORMAT_HANDLED), - booleanArrayOf(false), - ) - } - return Tracks(groups) - } - - @Test - fun `test MPV embedded`() { - val resource = javaClass.classLoader?.getResource("embedded_subs.json") - Assert.assertNotNull(resource) - val fileContents = Paths.get(resource!!.toURI()).readText() - val source = Json.decodeFromString<MediaSourceInfo>(fileContents) - val tracks = buildEmbeddedTracks(PlayerBackend.MPV) - Assert.assertEquals(7, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 1, - subtitleIndex = 4, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 2, - subtitleIndex = 4, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 1, - subtitleIndex = TrackIndex.DISABLED, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) - } - } - - @Test - fun `test ExoPlayer embedded`() { - val resource = javaClass.classLoader?.getResource("embedded_subs.json") - Assert.assertNotNull(resource) - val fileContents = Paths.get(resource!!.toURI()).readText() - val source = Json.decodeFromString<MediaSourceInfo>(fileContents) - val tracks = buildEmbeddedTracks(PlayerBackend.EXO_PLAYER) - Assert.assertEquals(7, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 1, - subtitleIndex = 4, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 2, - subtitleIndex = 4, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 2, - subtitleIndex = 6, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("7", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 1, - subtitleIndex = TrackIndex.DISABLED, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) - } - } - - @Test - fun `test MPV no embedded`() { - 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 = buildNoEmbeddedTracks(PlayerBackend.MPV) - Assert.assertEquals(5, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 2, - subtitleIndex = 0, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 0, - source = source, - ).also { result -> - Assert.assertTrue(result.bothSelected) - Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - } - - @Test - fun `test ExoPlayer no embedded`() { - 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 = buildNoEmbeddedTracks(PlayerBackend.EXO_PLAYER) - Assert.assertEquals(5, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 2, - subtitleIndex = 0, - source = source, - ).also { 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 MPV external`() { - val resource = javaClass.classLoader?.getResource("external_subs.json") - Assert.assertNotNull(resource) - val fileContents = Paths.get(resource!!.toURI()).readText() - val source = Json.decodeFromString<MediaSourceInfo>(fileContents) - val tracks = buildExternalTracks(PlayerBackend.MPV, 0) - Assert.assertEquals(6, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 0, - source = source, - ).also { result -> - Assert.assertTrue(result.audioSelected) - Assert.assertTrue(result.subtitleSelected) - Assert.assertEquals("0:1", result.trackSelectionParameters.getAudioOverride()?.id) - Assert.assertEquals("3:e:2", result.trackSelectionParameters.getSubtitleOverride()?.id) - } - - // Select embedded subtitles - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 5, - source = source, - ).also { result -> - Assert.assertTrue(result.audioSelected) - Assert.assertTrue(result.subtitleSelected) - Assert.assertEquals( - "0:1", - result.trackSelectionParameters.getAudioOverride()?.id, - ) - Assert.assertEquals( - "2:1", - result.trackSelectionParameters.getSubtitleOverride()?.id, - ) - } - } - - @Test - fun `test ExoPlayer external`() { - val resource = javaClass.classLoader?.getResource("external_subs.json") - Assert.assertNotNull(resource) - val fileContents = Paths.get(resource!!.toURI()).readText() - val source = Json.decodeFromString<MediaSourceInfo>(fileContents) - - buildExternalTracks(PlayerBackend.EXO_PLAYER, 0).also { tracks -> - Assert.assertEquals(6, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 0, - source = source, - ).also { result -> - Assert.assertTrue(result.audioSelected) - Assert.assertTrue(result.subtitleSelected) - Assert.assertEquals( - "0:1", - result.trackSelectionParameters.getAudioOverride()?.id, - ) - Assert.assertEquals( - "1:e:0", - result.trackSelectionParameters.getSubtitleOverride()?.id, - ) - } - - // Select embedded subtitles - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 5, - source = source, - ).also { result -> - Assert.assertTrue(result.audioSelected) - Assert.assertTrue(result.subtitleSelected) - Assert.assertEquals( - "0:1", - result.trackSelectionParameters.getAudioOverride()?.id, - ) - Assert.assertEquals( - "0:3", - result.trackSelectionParameters.getSubtitleOverride()?.id, - ) - } - } - - buildExternalTracks(PlayerBackend.EXO_PLAYER, 2).also { tracks -> - Assert.assertEquals(6, source.mediaStreams?.size) - - val trackSelectionParameters = TrackSelectionParameters.Builder().build() - - TrackSelectionUtils - .createTrackSelections( - trackSelectionParams = trackSelectionParameters, - tracks = tracks, - audioIndex = 3, - subtitleIndex = 2, - source = source, - ).also { result -> - Assert.assertTrue(result.audioSelected) - Assert.assertTrue(result.subtitleSelected) - Assert.assertEquals( - "0:1", - result.trackSelectionParameters.getAudioOverride()?.id, - ) - Assert.assertEquals( - "1:e:2", - result.trackSelectionParameters.getSubtitleOverride()?.id, - ) - } - } - } - - @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 deleted file mode 100644 index 7370f29e..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestTracks.kt +++ /dev/null @@ -1,595 +0,0 @@ -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/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt deleted file mode 100644 index 2e84e2ca..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.services.UpdateChecker -import com.github.damontecres.wholphin.services.getDownloadUrl -import com.github.damontecres.wholphin.util.Version -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import java.nio.file.Paths -import kotlin.io.path.readText - -class TestUpdateChecker { - lateinit var releaseJson: JsonObject - val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } - - lateinit var releaseV108Json: JsonObject - val assetsV108Json: JsonArray by lazy { releaseV108Json["assets"]!!.jsonArray } - - private val forgejoLatest: JsonObject by lazy { read("forgejo_release_latest.json") } - private val forgejoAssets: JsonArray by lazy { forgejoLatest["assets"]!!.jsonArray } - private val forgejoReleases: JsonArray by lazy { readElement("forgejo_releases.json").jsonArray } - private val suffixedAssets: JsonArray by lazy { read("forgejo_release_suffixed.json")["assets"]!!.jsonArray } - - private fun readElement(filename: String): JsonElement { - val resource = javaClass.classLoader?.getResource(filename) - Assert.assertNotNull(resource) - val fileContents = Paths.get(resource!!.toURI()).readText() - return Json.parseToJsonElement(fileContents) - } - - private fun read(filename: String): JsonObject = readElement(filename).jsonObject - - private fun forgejoDownload(name: String) = - "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/$name" - - @Before - fun setup() { - releaseJson = read("release_develop.json") - } - - // The GitHub fixtures are upstream Wholphin releases: their assets are named Wholphin*.apk, which - // the Wolfskin updater must not select. - - @Test - fun `Release does not choose upstream Wholphin assets`() { - val url = getDownloadUrl(assetsJson, false, listOf()) - Assert.assertNull(url) - } - - @Test - fun `Choose abi does not choose upstream Wholphin assets`() { - val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) - Assert.assertNull(url) - } - - @Test - fun `Choose unknown abi does not choose upstream Wholphin assets`() { - val url = getDownloadUrl(assetsJson, false, listOf("unknown")) - Assert.assertNull(url) - } - - @Test - fun `Debug does not choose upstream Wholphin assets`() { - val url = getDownloadUrl(assetsJson, true, listOf()) - Assert.assertNull(url) - } - - @Test - fun `Choose debug abi does not choose upstream Wholphin assets`() { - val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) - Assert.assertNull(url) - } - - @Test - fun `Test regular release does not choose upstream Wholphin assets`() { - releaseV108Json = read("release_v108.json") - Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("arm64-v8a"))) - Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a"))) - Assert.assertNull(getDownloadUrl(assetsV108Json, false, emptyList())) - Assert.assertNull(getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a"))) - } - - @Test - fun `Parse Forgejo latest release`() { - val release = UpdateChecker.parseRelease(forgejoLatest, debug = false, supportedABIs = listOf("arm64-v8a")) - checkNotNull(release) - Assert.assertEquals(Version.fromString("v1.0.9-2-g3094fc8c"), release.version) - Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), release.downloadUrl) - Assert.assertEquals("2026-10-20T09:05:00Z", release.publishedAt) - Assert.assertEquals(listOf("Subtitle preference fix"), release.notes) - } - - @Test - fun `Choose Wolfskin asset for abi`() { - Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), getDownloadUrl(forgejoAssets, false, listOf("arm64-v8a"))) - Assert.assertEquals(forgejoDownload("Wolfskin-armeabi-v7a.apk"), getDownloadUrl(forgejoAssets, false, listOf("armeabi-v7a"))) - } - - @Test - fun `Choose universal Wolfskin asset for unknown abi`() { - Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, listOf("unknown"))) - Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, emptyList())) - } - - @Test - fun `Debug asset is not offered when release has none`() { - Assert.assertNull(getDownloadUrl(forgejoAssets, true, listOf("arm64-v8a"))) - } - - @Test - fun `Choose debug ABI before universal and fall back for missing ABI`() { - for ((abis, name) in listOf( - listOf("arm64-v8a") to "Wolfskin-debug-arm64-v8a.apk", - listOf("unknown") to "Wolfskin-debug.apk", - emptyList<String>() to "Wolfskin-debug.apk", - )) { - Assert.assertEquals(forgejoDownload(name), getDownloadUrl(suffixedAssets, true, abis)) - } - } - - @Test - fun `Choose release suffixed ABI before universal and unsuffixed assets`() { - val assets = JsonArray(forgejoAssets + suffixedAssets) - for ((abis, name) in listOf( - listOf("arm64-v8a") to "Wolfskin-release-arm64-v8a.apk", - listOf("armeabi-v7a") to "Wolfskin-release.apk", - emptyList<String>() to "Wolfskin-release.apk", - )) { - Assert.assertEquals(forgejoDownload(name), getDownloadUrl(assets, false, abis)) - } - } - - @Test - fun `Release and debug assets do not cross build types`() { - val debugAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-debug") }) - val releaseAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-release") }) - Assert.assertNull(getDownloadUrl(debugAssets, false, listOf("arm64-v8a"))) - Assert.assertNull(getDownloadUrl(releaseAssets, true, listOf("arm64-v8a"))) - } - - @Test - fun `Find installed version in release list`() { - val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8c")) - Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) - } - - @Test - fun `Find release with different hash abbreviation length`() { - val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8")) - Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) - } - - @Test - fun `Find release with absent or zero commit count`() { - val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.10")) - Assert.assertEquals("v1.0.10-0-gfeedbee", release?.get("name")?.jsonPrimitive?.content) - val plainRelease = JsonObject(forgejoReleases.first().jsonObject + ("name" to JsonPrimitive("v1.0.10"))) - val plainReleases = JsonArray(listOf(plainRelease)) - Assert.assertEquals(plainReleases.first(), UpdateChecker.findReleaseByVersion(plainReleases, Version.fromString("1.0.10-0-gfeedbee"))) - } - - @Test - fun `Find no release for version missing from list`() { - Assert.assertNull(UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-3-gffffff"))) - } -} 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 deleted file mode 100644 index c291d067..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ /dev/null @@ -1,163 +0,0 @@ -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 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} ") - } -} - -/** - * Asserts that the object is not null - * - * Includes a [contract] to smart-cast the object to T - */ -@OptIn(ExperimentalContracts::class) -fun assertNotNull(obj: Any?) { - contract { - returns() implies (obj != null) - } - org.junit.Assert.assertNotNull(obj) -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt deleted file mode 100644 index 2c3648a2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt +++ /dev/null @@ -1,768 +0,0 @@ -package com.github.damontecres.wholphin.test - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Bundle -import android.os.Looper -import android.speech.RecognitionListener -import android.speech.SpeechRecognizer -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.components.VoiceInputState -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.slot -import io.mockk.unmockkStatic -import io.mockk.verify -import org.junit.After -import org.junit.Assert.assertEquals -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.Shadows.shadowOf -import org.robolectric.annotation.Config - -/** - * Unit tests for [VoiceInputManager] state machine logic. - * - * Uses Robolectric to provide Android framework classes (Intent, Bundle) - * and Mockk to mock [SpeechRecognizer] and simulate recognition callbacks - * without requiring a real microphone or emulator. - */ -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [28]) -class TestVoiceInputManager { - private lateinit var activity: Activity - private lateinit var speechRecognizer: SpeechRecognizer - private lateinit var listenerSlot: CapturingSlot<RecognitionListener> - private lateinit var manager: VoiceInputManager - private lateinit var audioManager: AudioManager - private lateinit var connectivityManager: ConnectivityManager - private lateinit var network: Network - private lateinit var networkCapabilities: NetworkCapabilities - - private val capturedListener: RecognitionListener - get() = listenerSlot.captured - - private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() - - @Before - fun setup() { - // Mock Activity - activity = mockk(relaxed = true) - - // Mock AudioManager - audioManager = mockk(relaxed = true) - every { audioManager.requestAudioFocus(any<AudioFocusRequest>()) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED - every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager - - // Mock ConnectivityManager with network available by default - connectivityManager = mockk(relaxed = true) - network = mockk() - networkCapabilities = mockk() - every { connectivityManager.activeNetwork } returns network - every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities - every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true - every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager - - // Mock SpeechRecognizer instance - speechRecognizer = mockk(relaxed = true) - listenerSlot = slot() - - // Capture the RecognitionListener when setRecognitionListener is called - every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs - - // Mock static factory method - mockkStatic(SpeechRecognizer::class) - every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer - every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true - - // Create the manager under test - manager = VoiceInputManager(activity) - } - - @After - fun teardown() { - unmockkStatic(SpeechRecognizer::class) - } - - // ========== Test Case 1: Initial State ========== - - @Test - fun `initial state is Idle`() { - assertEquals(VoiceInputState.Idle, manager.state.value) - } - - @Test - fun `initial soundLevel is zero`() { - assertEquals(0f, manager.soundLevel.value) - } - - @Test - fun `initial partialResult is empty`() { - assertEquals("", manager.partialResult.value) - } - - // ========== Test Case 2: Start Listening ========== - - @Test - fun `startListening transitions state to Starting`() { - manager.startListening() - idleMainLooper() - - assertEquals(VoiceInputState.Starting, manager.state.value) - } - - @Test - fun `onReadyForSpeech transitions state to Listening`() { - manager.startListening() - idleMainLooper() - capturedListener.onReadyForSpeech(null) - idleMainLooper() - - assertEquals(VoiceInputState.Listening, manager.state.value) - } - - @Test - fun `startListening creates SpeechRecognizer`() { - manager.startListening() - idleMainLooper() - - verify { SpeechRecognizer.createSpeechRecognizer(activity) } - } - - @Test - fun `startListening sets recognition listener`() { - manager.startListening() - idleMainLooper() - - verify { speechRecognizer.setRecognitionListener(any()) } - assertTrue(listenerSlot.isCaptured) - } - - @Test - fun `startListening calls recognizer startListening`() { - manager.startListening() - idleMainLooper() - - verify { speechRecognizer.startListening(any<Intent>()) } - } - - @Test - fun `startListening resets partialResult`() { - manager.startListening() - idleMainLooper() - capturedListener.onReadyForSpeech(null) - idleMainLooper() - capturedListener.onPartialResults(createResultsBundle("partial")) - idleMainLooper() - manager.stopListening() - idleMainLooper() - - manager.startListening() - idleMainLooper() - - assertEquals("", manager.partialResult.value) - } - - @Test - fun `startListening is ignored when already listening`() { - manager.startListening() - idleMainLooper() - capturedListener.onReadyForSpeech(null) - idleMainLooper() - manager.startListening() // Should be ignored - idleMainLooper() - - // Only one recognizer should be created - verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } - } - - @Test - fun `startListening is ignored when in Starting state`() { - manager.startListening() - idleMainLooper() - assertEquals(VoiceInputState.Starting, manager.state.value) - - manager.startListening() // Should be ignored - idleMainLooper() - - // Only one recognizer should be created - verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } - } - - // ========== Test Case 3: Permission Denied ========== - - @Test - fun `onPermissionDenied transitions to Error state`() { - manager.onPermissionDenied() - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - } - - @Test - fun `onPermissionDenied sets correct error resource`() { - manager.onPermissionDenied() - idleMainLooper() - - val errorState = manager.state.value as VoiceInputState.Error - assertEquals(R.string.voice_error_permissions, errorState.messageResId) - } - - // ========== Test Case 4: Result Success ========== - - @Test - fun `onResults transitions to Result state with correct text`() { - manager.startListening() - idleMainLooper() - - capturedListener.onResults(createResultsBundle("hello world")) - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Result) - assertEquals("hello world", (manager.state.value as VoiceInputState.Result).text) - } - - @Test - fun `onResults resets soundLevel to zero`() { - manager.startListening() - idleMainLooper() - capturedListener.onRmsChanged(5f) - idleMainLooper() - - capturedListener.onResults(createResultsBundle("test")) - idleMainLooper() - - assertEquals(0f, manager.soundLevel.value) - } - - @Test - fun `onResults with empty text transitions to Error state`() { - manager.startListening() - idleMainLooper() - - capturedListener.onResults(createResultsBundle("")) - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onResults with null bundle transitions to Error state`() { - manager.startListening() - idleMainLooper() - - capturedListener.onResults(null) - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onResults with blank text transitions to Error state`() { - manager.startListening() - idleMainLooper() - - capturedListener.onResults(createResultsBundle(" ")) - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - } - - // ========== Test Case 5: Error Handling ========== - - @Test - fun `onError transitions to Error state`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - } - - @Test - fun `onError maps ERROR_NETWORK to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) - idleMainLooper() - - assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_NO_MATCH to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_NO_MATCH) - idleMainLooper() - - assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_AUDIO to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_AUDIO) - idleMainLooper() - - assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_SERVER to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_SERVER) - idleMainLooper() - - assertEquals(R.string.voice_error_server, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_CLIENT to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_CLIENT) - idleMainLooper() - - assertEquals(R.string.voice_error_client, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_SPEECH_TIMEOUT to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) - idleMainLooper() - - assertEquals(R.string.voice_error_speech_timeout, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError ERROR_SPEECH_TIMEOUT is retryable`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) - idleMainLooper() - - assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) - } - - @Test - fun `onError maps ERROR_NETWORK_TIMEOUT to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_NETWORK_TIMEOUT) - idleMainLooper() - - assertEquals(R.string.voice_error_network_timeout, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_RECOGNIZER_BUSY to correct resource after retry exhausted`() { - manager.startListening() - idleMainLooper() - - // First BUSY triggers auto-retry - capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) - shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(350)) - - // Second BUSY exhausts retry count and shows error - capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) - idleMainLooper() - - assertEquals(R.string.voice_error_busy, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps ERROR_INSUFFICIENT_PERMISSIONS to correct resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) - idleMainLooper() - - assertEquals(R.string.voice_error_permissions, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError maps unknown error to unknown resource`() { - manager.startListening() - idleMainLooper() - - capturedListener.onError(999) // Unknown error code - idleMainLooper() - - assertEquals(R.string.voice_error_unknown, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `onError resets soundLevel to zero`() { - manager.startListening() - idleMainLooper() - capturedListener.onRmsChanged(5f) - idleMainLooper() - - capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) - idleMainLooper() - - assertEquals(0f, manager.soundLevel.value) - } - - // ========== Test Case 6: Cleanup/Lifecycle ========== - - @Test - fun `cleanup resets state to Idle`() { - manager.startListening() - idleMainLooper() - - manager.close() - idleMainLooper() - - assertEquals(VoiceInputState.Idle, manager.state.value) - } - - @Test - fun `cleanup calls cancel on recognizer`() { - manager.startListening() - idleMainLooper() - - manager.close() - - verify { speechRecognizer.cancel() } - } - - @Test - fun `cleanup calls destroy on recognizer`() { - manager.startListening() - idleMainLooper() - - manager.close() - - verify { speechRecognizer.destroy() } - } - - @Test - fun `cleanup resets soundLevel to zero`() { - manager.startListening() - idleMainLooper() - capturedListener.onRmsChanged(5f) - idleMainLooper() - - manager.close() - idleMainLooper() - - assertEquals(0f, manager.soundLevel.value) - } - - @Test - fun `cleanup resets partialResult to empty`() { - manager.startListening() - idleMainLooper() - capturedListener.onPartialResults(createResultsBundle("partial")) - idleMainLooper() - - manager.close() - idleMainLooper() - - assertEquals("", manager.partialResult.value) - } - - @Test - fun `stopListening triggers cleanup`() { - manager.startListening() - idleMainLooper() - - manager.stopListening() - idleMainLooper() - - assertEquals(VoiceInputState.Idle, manager.state.value) - verify { speechRecognizer.cancel() } - verify { speechRecognizer.destroy() } - } - - // ========== Additional Coverage ========== - - @Test - fun `onEndOfSpeech transitions to Processing state`() { - manager.startListening() - idleMainLooper() - - capturedListener.onEndOfSpeech() - idleMainLooper() - - assertEquals(VoiceInputState.Processing, manager.state.value) - } - - @Test - fun `onRmsChanged updates soundLevel with normalized value`() { - manager.startListening() - idleMainLooper() - - // RMS normalization: (rmsdB - (-2)) / (10 - (-2)) clamped to [0, 1] - // For rmsdB = 4: (4 - (-2)) / 12 = 6/12 = 0.5 - capturedListener.onRmsChanged(4f) - idleMainLooper() - - assertEquals(0.5f, manager.soundLevel.value, 0.01f) - } - - @Test - fun `onRmsChanged clamps high values to 1`() { - manager.startListening() - idleMainLooper() - - capturedListener.onRmsChanged(20f) // Above max - idleMainLooper() - - assertEquals(1f, manager.soundLevel.value) - } - - @Test - fun `onRmsChanged clamps low values to 0`() { - manager.startListening() - idleMainLooper() - - capturedListener.onRmsChanged(-10f) // Below min - idleMainLooper() - - assertEquals(0f, manager.soundLevel.value) - } - - @Test - fun `onPartialResults updates partialResult`() { - manager.startListening() - idleMainLooper() - - capturedListener.onPartialResults(createResultsBundle("hello")) - idleMainLooper() - - assertEquals("hello", manager.partialResult.value) - } - - @Test - fun `onPartialResults ignores blank text`() { - manager.startListening() - idleMainLooper() - capturedListener.onPartialResults(createResultsBundle("first")) - idleMainLooper() - - capturedListener.onPartialResults(createResultsBundle(" ")) - idleMainLooper() - - assertEquals("first", manager.partialResult.value) - } - - @Test - fun `acknowledge resets state to Idle`() { - manager.startListening() - idleMainLooper() - capturedListener.onResults(createResultsBundle("test")) - idleMainLooper() - - manager.acknowledge() - idleMainLooper() - - assertEquals(VoiceInputState.Idle, manager.state.value) - } - - @Test - fun `acknowledge works from Error state`() { - manager.onPermissionDenied() - idleMainLooper() - - manager.acknowledge() - idleMainLooper() - - assertEquals(VoiceInputState.Idle, manager.state.value) - } - - @Test - fun `onPermissionGranted calls startListening`() { - manager.onPermissionGranted() - idleMainLooper() - - assertEquals(VoiceInputState.Starting, manager.state.value) - verify { SpeechRecognizer.createSpeechRecognizer(activity) } - } - - @Test - fun `callbacks from previous recognizer are ignored after cleanup`() { - // Create two different mock recognizers to simulate real behavior - val firstRecognizer = mockk<SpeechRecognizer>(relaxed = true) - val secondRecognizer = mockk<SpeechRecognizer>(relaxed = true) - val firstListenerSlot = slot<RecognitionListener>() - val secondListenerSlot = slot<RecognitionListener>() - - every { firstRecognizer.setRecognitionListener(capture(firstListenerSlot)) } just Runs - every { secondRecognizer.setRecognitionListener(capture(secondListenerSlot)) } just Runs - - // Return different recognizers for each call - every { SpeechRecognizer.createSpeechRecognizer(activity) } returnsMany listOf(firstRecognizer, secondRecognizer) - - manager.startListening() - idleMainLooper() - val firstListener = firstListenerSlot.captured - - manager.close() - idleMainLooper() - manager.startListening() - idleMainLooper() - - // Simulate callback from the old (zombie) recognizer - firstListener.onResults(createResultsBundle("zombie result")) - idleMainLooper() - - // State should remain Starting (from the second startListening), not Result - assertEquals(VoiceInputState.Starting, manager.state.value) - } - - @Test - fun `isAvailable returns mocked value`() { - assertTrue(manager.isAvailable) - } - - // ========== Test Case: Network Fast-Fail ========== - - @Test - fun `startListening fails immediately when no network`() { - every { connectivityManager.activeNetwork } returns null - - manager.startListening() - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `startListening fails immediately when no network capabilities`() { - every { connectivityManager.getNetworkCapabilities(network) } returns null - - manager.startListening() - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `startListening fails immediately when no internet capability`() { - every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false - - manager.startListening() - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `network error from fast-fail is retryable`() { - every { connectivityManager.activeNetwork } returns null - - manager.startListening() - idleMainLooper() - - assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) - } - - @Test - fun `startListening does not create recognizer when no network`() { - every { connectivityManager.activeNetwork } returns null - - manager.startListening() - idleMainLooper() - - verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } - } - - // ========== Test Case: Audio Focus ========== - - @Test - fun `startListening fails when audio focus not granted`() { - every { audioManager.requestAudioFocus(any<AudioFocusRequest>()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED - - manager.startListening() - idleMainLooper() - - assertTrue(manager.state.value is VoiceInputState.Error) - assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) - } - - @Test - fun `audio focus error is retryable`() { - every { audioManager.requestAudioFocus(any<AudioFocusRequest>()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED - - manager.startListening() - idleMainLooper() - - assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) - } - - @Test - fun `startListening does not create recognizer when audio focus denied`() { - every { audioManager.requestAudioFocus(any<AudioFocusRequest>()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED - - manager.startListening() - idleMainLooper() - - verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } - } - - @Test - fun `audio focus is abandoned when recognizer is destroyed`() { - manager.startListening() - idleMainLooper() - - manager.close() - - verify { audioManager.abandonAudioFocusRequest(any()) } - } - - @Test - fun `startListening requests audio focus`() { - manager.startListening() - idleMainLooper() - - verify { audioManager.requestAudioFocus(any<AudioFocusRequest>()) } - } - - // ========== Helper Functions ========== - - private fun createResultsBundle(text: String): Bundle = - Bundle().apply { - putStringArrayList( - SpeechRecognizer.RESULTS_RECOGNITION, - arrayListOf(text), - ) - } -} - -private typealias CapturingSlot<T> = io.mockk.CapturingSlot<T> diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt deleted file mode 100644 index c046eea2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.github.damontecres.wholphin.test - -import android.app.Activity -import android.content.Context -import android.media.AudioFocusRequest -import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Looper -import android.speech.RecognitionListener -import android.speech.SpeechRecognizer -import com.github.damontecres.wholphin.ui.components.VoiceInputManager -import com.github.damontecres.wholphin.ui.components.VoiceInputState -import io.mockk.CapturingSlot -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.slot -import io.mockk.unmockkStatic -import org.junit.After -import org.junit.Assert.assertEquals -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.Shadows.shadowOf -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [28]) -class TestVoiceInputManagerAutoFocus { - private lateinit var activity: Activity - private lateinit var speechRecognizer: SpeechRecognizer - private lateinit var manager: VoiceInputManager - private lateinit var audioManager: AudioManager - private lateinit var connectivityManager: ConnectivityManager - private lateinit var listenerSlot: CapturingSlot<RecognitionListener> - - // We need to capture the OnAudioFocusChangeListener passed to AudioFocusRequest - private val focusRequestSlot = slot<AudioFocusRequest>() - - private val capturedListener: RecognitionListener - get() = listenerSlot.captured - - private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() - - @Before - fun setup() { - activity = mockk(relaxed = true) - audioManager = mockk(relaxed = true) - connectivityManager = mockk(relaxed = true) - - // Mock network availability - val mockNetwork = mockk<Network>() - val mockCapabilities = mockk<NetworkCapabilities>() - every { connectivityManager.activeNetwork } returns mockNetwork - every { connectivityManager.getNetworkCapabilities(mockNetwork) } returns mockCapabilities - every { mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true - - // Capture the AudioFocusRequest built by the specific line in VoiceInputManager - every { audioManager.requestAudioFocus(capture(focusRequestSlot)) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED - every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager - every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager - - speechRecognizer = mockk(relaxed = true) - listenerSlot = slot() - every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs - - mockkStatic(SpeechRecognizer::class) - every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer - every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true - - manager = VoiceInputManager(activity) - } - - @After - fun teardown() { - unmockkStatic(SpeechRecognizer::class) - } - - @Test - fun `transient audio focus loss is ignored`() { - // Start listening - manager.startListening() - idleMainLooper() - capturedListener.onReadyForSpeech(null) - idleMainLooper() - assertEquals(VoiceInputState.Listening, manager.state.value) - - // Verify requestAudioFocus was called - assertTrue(focusRequestSlot.isCaptured) - - // Get listener via reflection since AudioFocusRequest doesn't expose it - val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") - field.isAccessible = true - val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener - - // Invoke onAudioFocusChange directly on the listener - listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) - - idleMainLooper() - - // Assert state is STILL Listening (Logic correctly ignores it) - assertEquals(VoiceInputState.Listening, manager.state.value) - } - - @Test - fun `permanent audio focus loss stops listening`() { - // Start listening - manager.startListening() - idleMainLooper() - capturedListener.onReadyForSpeech(null) - idleMainLooper() - - // Get listener via reflection since AudioFocusRequest doesn't expose it - val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") - field.isAccessible = true - val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener - - // Invoke onAudioFocusChange directly on the listener - listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) - - idleMainLooper() - - // Assert state is NOW Idle (Logic correctly stops) - assertEquals(VoiceInputState.Idle, manager.state.value) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt deleted file mode 100644 index fdd45e6a..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/test/VersionCompareTests.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.damontecres.wholphin.test - -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.util.Version -import org.junit.Assert -import org.junit.Test - -class VersionCompareTests { - companion object { - val V_0_2_0 = Version.fromString("v0.2.0") - val V_0_1_0 = Version.fromString("v0.1.0") - val V_0_1_1 = Version.fromString("v0.1.1") - - val V_0_2_0_0 = Version.fromString("v0.2.0-0-gabc1234") - val V_0_2_0_7 = Version.fromString("v0.2.0-7-gabc1234") - val V_0_1_0_7 = Version.fromString("v0.1.0-7-gabc1234") - val V_0_1_0_6 = Version.fromString("v0.1.0-6-gabc1234") - val V_0_1_1_4 = Version.fromString("v0.1.1-4-gabc1234") - val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234") - } - - @Test - fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() { - val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName"))) - Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion) - } - - @Test - fun testIsAtLeast() { - Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0)) - Assert.assertTrue(V_0_2_0.isAtLeast(V_0_1_0)) - Assert.assertTrue(V_0_1_1.isAtLeast(V_0_1_0)) - Assert.assertFalse(V_0_1_0.isAtLeast(V_0_2_0)) - Assert.assertFalse(V_0_1_1.isAtLeast(V_0_2_0)) - - Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0_0)) - Assert.assertTrue(V_0_2_0_0.isAtLeast(V_0_2_0)) - Assert.assertTrue(V_0_1_0_7.isAtLeast(V_0_1_0_7)) - Assert.assertTrue(V_0_1_0_7.isAtLeast(V_0_1_0_6)) - - Assert.assertFalse(V_0_1_0_6.isAtLeast(V_0_1_0_7)) - } - - @Test - fun testisGreaterThan() { - Assert.assertFalse(V_0_2_0.isGreaterThan(V_0_2_0)) - Assert.assertTrue(V_0_2_0.isGreaterThan(V_0_1_0)) - Assert.assertTrue(V_0_1_1.isGreaterThan(V_0_1_0)) - Assert.assertFalse(V_0_1_0.isGreaterThan(V_0_2_0)) - - Assert.assertFalse(V_0_2_0.isGreaterThan(V_0_2_0_0)) - Assert.assertFalse(V_0_2_0_0.isGreaterThan(V_0_2_0)) - Assert.assertFalse(V_0_1_0_7.isGreaterThan(V_0_1_0_7)) - Assert.assertTrue(V_0_1_0_7.isGreaterThan(V_0_1_0_6)) - - Assert.assertTrue(V_0_2_0_7.isGreaterThan(V_0_2_0_0)) - Assert.assertTrue(V_0_1_1_4.isGreaterThan(V_0_1_0_6)) - Assert.assertTrue(V_0_1_1_4.isGreaterThan(V_0_1_0)) - Assert.assertFalse(V_0_1_1_4.isGreaterThan(V_0_1_1_5)) - Assert.assertTrue(V_0_1_1_5.isGreaterThan(V_0_1_1_4)) - } - - @Test - fun testEqualOrBefore() { - Assert.assertTrue(V_0_2_0.isEqualOrBefore(V_0_2_0)) - Assert.assertTrue(V_0_1_1.isEqualOrBefore(V_0_2_0)) - Assert.assertTrue(V_0_1_1.isEqualOrBefore(V_0_1_1_5)) - Assert.assertTrue(V_0_1_1_4.isEqualOrBefore(V_0_1_1_5)) - - Assert.assertFalse(V_0_2_0.isEqualOrBefore(V_0_1_1)) - } - - @Test - fun testLessThan() { - Assert.assertFalse(V_0_2_0.isLessThan(V_0_2_0)) - Assert.assertTrue(V_0_1_1.isLessThan(V_0_2_0)) - Assert.assertTrue(V_0_1_1.isLessThan(V_0_1_1_5)) - Assert.assertTrue(V_0_1_1_4.isLessThan(V_0_1_1_5)) - - Assert.assertFalse(V_0_2_0.isLessThan(V_0_1_1)) - } - - @Test - fun testToString() { - Assert.assertEquals("v0.2.0", V_0_2_0.toString()) - Assert.assertEquals("v0.2.0", V_0_2_0_0.toString()) - Assert.assertEquals("v0.3.0", Version(0, 3, 0, null).toString()) - Assert.assertEquals("v0.3.0", Version(0, 3, 0, 0, "abc1234").toString()) - Assert.assertEquals("v0.2.0-1-gabc1234", Version(0, 2, 0, 1, "abc1234").toString()) - Assert.assertEquals("v0.2.0-7-gabc1234", V_0_2_0_7.toString()) - } -} 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 deleted file mode 100644 index 2e2c3a89..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.github.damontecres.wholphin.ui - -import android.app.Application -import android.content.pm.ActivityInfo -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.test.requestFocus -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.services.ScreensaverService -import com.github.damontecres.wholphin.services.ScreensaverState -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import com.github.damontecres.wholphin.test.TestActivity -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.util.LoadingState -import 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 kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.quickConnectApi -import org.jellyfin.sdk.api.operations.QuickConnectApi -import org.jellyfin.sdk.discovery.DiscoveryService -import org.jellyfin.sdk.discovery.RecommendedServerInfo -import org.jellyfin.sdk.discovery.RecommendedServerInfoScore -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.PublicSystemInfo -import org.junit.After -import org.junit.Assert -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestWatcher -import org.junit.runner.Description -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import javax.inject.Inject - -@HiltAndroidTest -@Config(application = HiltTestApplication::class, sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class BasicUiTests { - @get:Rule(order = 0) - var hiltRule = HiltAndroidRule(this) - - // From https://github.com/robolectric/robolectric/pull/4736#issuecomment-1831034882 - @get:Rule(order = 1) - val addActivityToRobolectricRule = - object : TestWatcher() { - override fun starting(description: Description?) { - super.starting(description) - val appContext: Application = ApplicationProvider.getApplicationContext() - val activityInfo = - ActivityInfo().apply { - name = TestActivity::class.java.name - packageName = appContext.packageName - } - shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo) - } - } - - @get:Rule(order = 2) - val composeTestRule = createAndroidComposeRule<TestActivity>() - - @Inject - lateinit var jellyfin: Jellyfin - - @Inject - lateinit var api: ApiClient - - @Inject - lateinit var setupNavigationManager: SetupNavigationManager - - lateinit var screensaverService: ScreensaverService - - lateinit var switchServerViewModel: SwitchServerViewModel - - val discovery: DiscoveryService = mockk() - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setup() { - WholphinDispatchers.configure(TestModule.testDispatcher) - - hiltRule.inject() - screensaverService = mockk(relaxed = true) - every { screensaverService.state } returns - MutableStateFlow( - ScreensaverState( - false, - false, - false, - false, - ), - ) - - every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api - every { jellyfin.discovery } returns discovery - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - WholphinDispatchers.reset() - } - - /** - * Tests successfully entering and submitting a server URL - */ - @OptIn(ExperimentalTestApi::class) - @Test - fun test_enter_server_url_success() { - coEvery { discovery.getRecommendedServers("localhost") } returns - listOf( - RecommendedServerInfo( - address = "localhost", - responseTime = 50, - score = RecommendedServerInfoScore.GREAT, - issues = emptyList(), - systemInfo = - Result.success( - PublicSystemInfo( - id = UUID.randomUUID().toString(), - startupWizardCompleted = true, - ), - ), - ), - ) - val quickConnectApi = mockk<QuickConnectApi>() - every { api.quickConnectApi } returns quickConnectApi - coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) - - composeTestRule.setContent { - WholphinTheme { - switchServerViewModel = hiltViewModel() - SwitchServerContent( - modifier = Modifier.fillMaxSize(), - viewModel = switchServerViewModel, - ) - } - } - - TestModule.testDispatcher.scheduler.advanceUntilIdle() - - composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() - composeTestRule.onNodeWithTag("add_server").performKeyInput { - pressKey(Key.DirectionDown) // TODO fix focus - } - composeTestRule - .onNodeWithTag("add_server") - .assertIsFocused() - .performClickEnter() - - composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter server address").performClickEnter() - composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() - - composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule - .onNodeWithText("Submit") - .assertIsEnabled() - .requestFocus() - .performClickEnter() - - TestModule.testDispatcher.scheduler.advanceUntilIdle() - - switchServerViewModel.state.value.addServerState.let { - if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) - } - -// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } - Assert.assertEquals(1, setupNavigationManager.backStack.size) - Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) - } - - /** - * Tests entering and submitting a server URL that returns an error - */ - @OptIn(ExperimentalTestApi::class) - @Test - fun test_enter_server_url_error() { - coEvery { discovery.getRecommendedServers("localhost") } returns - listOf( - RecommendedServerInfo( - address = "localhost", - responseTime = 50, - score = RecommendedServerInfoScore.GREAT, - issues = emptyList(), - systemInfo = - Result.success( - PublicSystemInfo( - id = null, // Invalid - startupWizardCompleted = false, - ), - ), - ), - ) - val quickConnectApi = mockk<QuickConnectApi>() - every { api.quickConnectApi } returns quickConnectApi - coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) - - composeTestRule.setContent { - WholphinTheme { - switchServerViewModel = hiltViewModel() - SwitchServerContent( - modifier = Modifier.fillMaxSize(), - viewModel = switchServerViewModel, - ) - } - } - - TestModule.testDispatcher.scheduler.advanceUntilIdle() - - composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() - composeTestRule.onNodeWithTag("add_server").performKeyInput { - pressKey(Key.DirectionDown) // TODO fix focus - } - composeTestRule - .onNodeWithTag("add_server") - .assertIsFocused() - .performClickEnter() - - composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() - composeTestRule.onNodeWithText("Enter server address").performClickEnter() - composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() - - composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") - composeTestRule - .onNodeWithText("Submit") - .assertIsEnabled() - .requestFocus() - .performClickEnter() - - TestModule.testDispatcher.scheduler.advanceUntilIdle() - - Assert.assertTrue(switchServerViewModel.state.value.addServerState is LoadingState.Error) - - composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt deleted file mode 100644 index b266bb96..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.github.damontecres.wholphin.ui - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler -import androidx.datastore.dataStoreFile -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile -import androidx.room.Room -import androidx.work.WorkManager -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.AppDatabase -import com.github.damontecres.wholphin.data.ItemPlaybackDao -import com.github.damontecres.wholphin.data.JellyfinServerDao -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao -import com.github.damontecres.wholphin.data.Migrations -import com.github.damontecres.wholphin.data.MostRecentServerProvider -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.SeriesTrackChoiceDao -import com.github.damontecres.wholphin.data.ServerPreferencesDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer -import com.github.damontecres.wholphin.services.SeerrApi -import com.github.damontecres.wholphin.services.TestMostRecentServerProvider -import com.github.damontecres.wholphin.services.hilt.AppModule -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import com.github.damontecres.wholphin.services.hilt.DatabaseModule -import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope -import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher -import com.github.damontecres.wholphin.services.hilt.DeviceModule -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient -import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory -import com.github.damontecres.wholphin.util.WholphinDispatchers -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dagger.hilt.testing.TestInstallIn -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.test.StandardTestDispatcher -import okhttp3.OkHttpClient -import org.jellyfin.sdk.Jellyfin -import org.jellyfin.sdk.JellyfinOptions -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder -import org.jellyfin.sdk.api.okhttp.OkHttpFactory -import org.jellyfin.sdk.model.ClientInfo -import org.jellyfin.sdk.model.DeviceInfo -import timber.log.Timber -import javax.inject.Singleton - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [DeviceModule::class, AppModule::class], -) -object TestModule { - val testDispatcher = StandardTestDispatcher() - - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = DeviceInfo("test_device_id", "test_device") - - @Provides - @Singleton - fun clientInfo( - @ApplicationContext context: Context, - ): ClientInfo = - ClientInfo( - name = context.getString(R.string.app_name), - version = BuildConfig.VERSION_NAME, - ) - - @StandardOkHttpClient - @Provides - @Singleton - fun okHttpClient() = - OkHttpClient - .Builder() - .apply { - // TODO user agent, timeouts, logging, etc - }.build() - - @AuthOkHttpClient - @Provides - @Singleton - fun authOkHttpClient( - serverRepository: ServerRepository, - @StandardOkHttpClient okHttpClient: OkHttpClient, - clientInfo: ClientInfo, - deviceInfo: DeviceInfo, - ) = okHttpClient - .newBuilder() - .addInterceptor { - val request = it.request() - val newRequest = - serverRepository.currentUser?.accessToken?.let { token -> - request - .newBuilder() - .addHeader( - "Authorization", - AuthorizationHeaderBuilder.buildHeader( - clientName = clientInfo.name, - clientVersion = clientInfo.version, - deviceId = deviceInfo.id, - deviceName = deviceInfo.name, - accessToken = token, - ), - ).build() - } - it.proceed(newRequest ?: request) - }.build() - - @Provides - @Singleton - fun okHttpFactory( - @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) - - @Provides - @Singleton - fun jellyfin( - okHttpFactory: CoroutineContextApiClientFactory, - @ApplicationContext context: Context, - clientInfo: ClientInfo, - deviceInfo: DeviceInfo, - ): Jellyfin { - val jellyfin: Jellyfin = mockk() - every { jellyfin.clientInfo } returns clientInfo - every { jellyfin.deviceInfo } returns deviceInfo - every { jellyfin.options } returns - JellyfinOptions( - context, - clientInfo, - deviceInfo, - okHttpFactory, - okHttpFactory, - Jellyfin.minimumVersion, - ) - every { jellyfin.createApi(any()) } returns apiClient(jellyfin) - every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) - return jellyfin - } - - @Provides - @Singleton - fun apiClient(jellyfin: Jellyfin): ApiClient { - val api: ApiClient = mockk() - every { api.clientInfo } returns jellyfin.clientInfo!! - every { api.deviceInfo } returns jellyfin.deviceInfo!! - every { api.update(any(), any(), any(), any()) } returns Unit - return api - } - - @Provides - @Singleton - @IoDispatcher - fun ioDispatcher(): CoroutineDispatcher = testDispatcher - - @Provides - @Singleton - @IoCoroutineScope - fun ioCoroutineScope( - @IoDispatcher dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - - @Provides - @Singleton - @DefaultDispatcher - fun defaultDispatcher(): CoroutineDispatcher = testDispatcher - - @Provides - @Singleton - @DefaultCoroutineScope - fun defaultCoroutineScope( - @DefaultDispatcher dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) - - @Provides - @Singleton - fun workManager( - @ApplicationContext context: Context, - ): WorkManager = mockk() - - @Provides - @Singleton - fun seerrApi( - @StandardOkHttpClient okHttpClient: OkHttpClient, - ): SeerrApi = mockk() - - @Provides - @Singleton - fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() -} - -@Module -@TestInstallIn( - components = [SingletonComponent::class], - replaces = [DatabaseModule::class], -) -object TestDatabaseModule { - @Module - @InstallIn(SingletonComponent::class) - object DatabaseModule { - @Provides - @Singleton - fun database( - @ApplicationContext context: Context, - ): AppDatabase = - Room - .inMemoryDatabaseBuilder( - context, - AppDatabase::class.java, - ).addMigrations(Migrations.Migrate2to3) - .allowMainThreadQueries() - .setQueryCallback({ sqlQuery, args -> - Timber.v("sqlQuery=$sqlQuery, args=$args") - }, WholphinDispatchers.IO.asExecutor()) - .build() - - @Provides - @Singleton - fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() - - @Provides - @Singleton - fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() - - @Provides - @Singleton - fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() - - @Provides - @Singleton - fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() - - @Provides - @Singleton - fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() - - @Provides - @Singleton - fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() - - @Provides - @Singleton - fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() - - @Provides - @Singleton - fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() - - @Provides - @Singleton - fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() - - @Provides - @Singleton - fun userPreferencesDataStore( - @ApplicationContext context: Context, - userPreferencesSerializer: AppPreferencesSerializer, - ): DataStore<AppPreferences> = - DataStoreFactory.create( - serializer = userPreferencesSerializer, - produceFile = { context.dataStoreFile("app_preferences.pb") }, - corruptionHandler = - ReplaceFileCorruptionHandler( - produceNewData = { AppPreferences.getDefaultInstance() }, - ), - ) - - @Provides - @Singleton - fun keyValueDataStore( - @ApplicationContext context: Context, - ): DataStore<Preferences> = - PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("key_value") }, - ) - } -} 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 deleted file mode 100644 index 6d30a600..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.damontecres.wholphin.ui - -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import org.jellyfin.sdk.api.client.Response -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult - -@OptIn(ExperimentalTestApi::class) -fun SemanticsNodeInteraction.performClickEnter() = - performKeyInput { - pressKey(Key.DirectionCenter) - } - -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/WolfskinThemeColorsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt deleted file mode 100644 index e262e2ad..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.damontecres.wholphin.ui - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance -import org.justinvisser.wolfskin.design.WolfskinColors -import org.junit.Assert.assertTrue -import org.junit.Test - -/** Normal and focused TV text must remain readable on the fork's dark palette. */ -class WolfskinThemeColorsTest { - @Test - fun darkPaletteHasReadableNormalAndFocusedText() { - val television = WolfskinColors.darkScheme - val material = WolfskinColors.darkSchemeMaterial - val textAndBackgrounds = - listOf( - television.onBackground to television.background, - television.onSurface to television.surface, - television.onSurfaceVariant to television.surfaceVariant, - television.onPrimary to television.primary, - television.onPrimaryContainer to television.primaryContainer, - television.onSecondary to television.secondary, - television.onSecondaryContainer to television.secondaryContainer, - television.onTertiary to television.tertiary, - television.onTertiaryContainer to television.tertiaryContainer, - material.onBackground to material.background, - material.onSurface to material.surface, - material.onPrimary to material.primary, - material.onPrimaryContainer to material.primaryContainer, - ) - textAndBackgrounds.forEach { (text, background) -> - assertReadable(text, background) - } - } - - private fun assertReadable(text: Color, background: Color) { - val textLuminance = text.luminance() - val backgroundLuminance = background.luminance() - val contrast = - (maxOf(textLuminance, backgroundLuminance) + .05f) / - (minOf(textLuminance, backgroundLuminance) + .05f) - assertTrue("Text contrast $contrast is below 4.5:1", contrast >= 4.5f) - } -} 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 deleted file mode 100644 index ba7f1e14..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt +++ /dev/null @@ -1,243 +0,0 @@ -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.GetAlbumArtistsHandler -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.GetAlbumArtistsRequest -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 albumArtistsRequest = slot<GetAlbumArtistsRequest>() - 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(GetAlbumArtistsHandler) - 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 { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns - successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT) - coEvery { GetPersonsHandler.execute(mockApi, any()) } returns - successQueryResult(totalRecordCount = PERSON_COUNT) - } - - @After - fun tearDown() { - WholphinDispatchers.reset() - unmockkObject(GetItemsRequestHandler) - unmockkObject(GetArtistsHandler) - unmockkObject(GetAlbumArtistsHandler) - unmockkObject(GetPersonsHandler) - } - - private fun createViewModel( - filter: GetItemsFilter, - viewOptions: ViewOptions = ViewOptionsSquare, - ) = 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 = viewOptions, - ) - - /** 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>()) } - } - - @Test - fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() = - runTest(testDispatcher) { - val viewModel = - createViewModel( - GetItemsFilter(override = GetItemsFilterOverride.ARTIST), - ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)), - ) - advanceUntilIdle() - - val position = viewModel.positionOfLetter('K') - - assertEquals(ALBUM_ARTIST_COUNT, position) - assertEquals("K", albumArtistsRequest.captured.nameLessThan) - assertEquals(libraryId, albumArtistsRequest.captured.parentId) - coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) } - } - - @Test - fun `changing the artist source reloads from the album artists endpoint`() = - runTest(testDispatcher) { - val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) - advanceUntilIdle() - coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) } - - viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST))) - advanceUntilIdle() - - coVerify { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) } - } - - /** - * 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 ALBUM_ARTIST_COUNT = 215 - private const val PERSON_COUNT = 12 - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt deleted file mode 100644 index 4fb3dc5c..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt +++ /dev/null @@ -1,281 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import com.github.damontecres.wholphin.api.seerr.MoviesApi -import com.github.damontecres.wholphin.api.seerr.SeerrApiClient -import com.github.damontecres.wholphin.api.seerr.TvApi -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb -import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails -import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.SourceRatings -import com.github.damontecres.wholphin.preferences.DisplayToggle -import com.github.damontecres.wholphin.services.SeerrRatingsService -import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.ui.successResponse -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.InterfaceCustomization -import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -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.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.util.EnumSet -import java.util.UUID - -/** - * What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and - * the Jellyfin rating of an item is never drawn - */ -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class SourceRatingsRowTest { - @get:Rule val rule = createComposeRule() - - private val moviesApi = mockk<MoviesApi>() - private val tvApi = mockk<TvApi>() - private val seerrApi = - mockk<SeerrApiClient> { - every { moviesApi } returns this@SourceRatingsRowTest.moviesApi - every { tvApi } returns this@SourceRatingsRowTest.tvApi - } - private val seerrService = - mockk<SeerrService> { - every { api } returns seerrApi - every { active } returns flowOf(true) - } - private val userLibraryApi = mockk<UserLibraryApi>() - private val jellyfin = mockk<ApiClient> { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi } - private val service = SeerrRatingsService(seerrService, jellyfin) - - private val seriesId = UUID.randomUUID() - private val seriesTmdb = 1396 - - /** Every item carries Jellyfin ratings that must never be drawn */ - private fun item( - type: BaseItemKind, - tmdb: Int? = null, - seriesId: UUID? = null, - ) = BaseItem( - BaseItemDto( - id = UUID.randomUUID(), - type = type, - name = "Title", - providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) }, - seriesId = seriesId, - communityRating = 7.8f, - criticRating = 59f, - ), - ) - - private fun movieRatings() { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns - MovieMovieIdRatingscombinedGet200Response( - rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"), - imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10), - ) - } - - private fun seriesRatings() { - coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns - TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled") - coEvery { userLibraryApi.getItem(seriesId) } returns - successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb"))) - } - - /** - * Shows the quick details of the items, after what a screen's wait for Seerr would have loaded - */ - private fun show( - vararg items: BaseItem, - toggles: EnumSet<DisplayToggle> = EnumSet.allOf(DisplayToggle::class.java), - ) { - runBlocking { - items.forEach { service.forItem(it, 0).collect { } } - } - rule.setContent { - WholphinTheme { - CompositionLocalProvider( - LocalSeerrRatingsService provides service, - LocalInterfaceCustomization provides InterfaceCustomization(toggles), - ) { - Column { - items.forEach { QuickDetails(it, null) } - } - } - } - } - rule.waitForIdle() - } - - private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists() - - private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist() - - private fun assertJellyfinRatingNotShown() { - assertNotShown("7.8") - assertNotShown("59%") - } - - @Test - fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() { - movieRatings() - show(item(BaseItemKind.MOVIE, 603)) - assertShown("96%") - assertShown("90%") - assertShown("8.8") - assertJellyfinRatingNotShown() - } - - @Test - fun seriesShowsItsRatings() { - seriesRatings() - show(item(BaseItemKind.SERIES, seriesTmdb)) - assertShown("81%") - assertShown("45%") - assertJellyfinRatingNotShown() - } - - @Test - fun episodeAndSeasonShowTheirSeriesRatings() { - seriesRatings() - show(item(BaseItemKind.EPISODE, seriesId = seriesId)) - assertShown("81%") - assertJellyfinRatingNotShown() - } - - private fun episodeWithScores(): BaseItem { - seriesRatings() - coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns - SeasonDetails(episodes = listOf(SeasonEpisode(episodeNumber = 1, voteAverage = 8.1.toBigDecimal(), voteCount = 50))) - return BaseItem( - BaseItemDto( - id = UUID.randomUUID(), - type = BaseItemKind.EPISODE, - providerIds = mapOf("Tmdb" to "331212"), - seriesId = seriesId, - parentIndexNumber = 5, - indexNumber = 1, - communityRating = 7.8f, - criticRating = 59f, - ), - ) - } - - @Test - fun episodeShowsItsOwnLabelledTmdbScoreNextToTheSeriesRottenTomatoes() { - show(episodeWithScores()) - assertShown("81%") - assertShown("45%") - assertShown("Ep 8.1") - assertJellyfinRatingNotShown() - } - - @Test - fun episodeScoreFollowsTheCommunityRatingSetting() { - show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) - assertShown("81%") - assertNotShown("Ep 8.1") - } - - @Test - fun episodeScoreStaysWhenRottenTomatoesIsHidden() { - show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) - assertShown("Ep 8.1") - assertNotShown("81%") - } - - @Test - fun seasonShowsItsSeriesRatings() { - seriesRatings() - show(item(BaseItemKind.SEASON, seriesId = seriesId)) - assertShown("45%") - assertJellyfinRatingNotShown() - } - - @Test - fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() { - movieRatings() - seriesRatings() - show( - *listOf( - BaseItemKind.BOX_SET, - BaseItemKind.MUSIC_ALBUM, - BaseItemKind.MUSIC_ARTIST, - BaseItemKind.PHOTO, - BaseItemKind.PROGRAM, - ).map { item(it, 603, seriesId) }.toTypedArray(), - ) - assertNotShown("96%") - assertNotShown("81%") - assertJellyfinRatingNotShown() - } - - @Test - fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() { - coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response() - coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns - mockk { - every { voteAverage } returns null - every { voteCount } returns null - } - show(item(BaseItemKind.MOVIE, 5)) - assertJellyfinRatingNotShown() - } - - @Test - fun theDisplaySettingsStillControlWhichSourcesAreDrawn() { - movieRatings() - show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) - assertShown("8.8") - assertNotShown("96%") - } - - @Test - fun criticSettingHidesImdb() { - movieRatings() - show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) - assertShown("96%") - assertNotShown("8.8") - } - - @Test - fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() { - val ratings = - SourceRatings.of( - rtCriticsScore = 96, - rtCriticsLabel = "Certified Fresh", - rtAudienceScore = 30, - rtAudienceLabel = "Spilled", - imdbScore = 8.8, - imdbVotes = 10, - ) - val entries = ratings.visibleEntries(community = true, critic = true) - val text = ratingsText(entries, leadingDot = false, episodeLabel = "Ep") - - assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%")) - assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8")) - // dots only between entries, or also in front when something precedes the ratings - assertEquals(2, text.text.split("\u2022").size - 1) - assertEquals(3, ratingsText(entries, leadingDot = true, episodeLabel = "Ep").text.split("\u2022").size - 1) - } -} 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 deleted file mode 100644 index 0b4cece1..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt +++ /dev/null @@ -1,427 +0,0 @@ -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/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt deleted file mode 100644 index 511dbaa2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ /dev/null @@ -1,276 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.lifecycle.SavedStateHandle -import com.github.damontecres.wholphin.data.ItemPlaybackDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.Playlist -import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.PlaylistCreationResult -import com.github.damontecres.wholphin.services.PlaylistCreator -import com.github.damontecres.wholphin.services.StreamChoiceReason -import com.github.damontecres.wholphin.services.StreamChoiceResult -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.preferences.ExternalPlayerApp -import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers -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.mockkStatic -import io.mockk.unmockkStatic -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.playStateApi -import org.jellyfin.sdk.api.client.extensions.subtitleApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.videosApi -import org.jellyfin.sdk.api.operations.PlayStateApi -import org.jellyfin.sdk.api.operations.SubtitleApi -import org.jellyfin.sdk.api.operations.UserLibraryApi -import org.jellyfin.sdk.api.operations.VideosApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.inWholeTicks -import org.junit.After -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -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 -import kotlin.time.Duration.Companion.minutes - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [30]) -class PlayExternalViewModelTest { - private val testDispatcher = StandardTestDispatcher() - private val context = mockk<Context>() - private val api = mockk<ApiClient>() - private val userLibraryApi = mockk<UserLibraryApi>() - private val videosApi = mockk<VideosApi>() - private val subtitleApi = mockk<SubtitleApi>() - private val playStateApi = mockk<PlayStateApi>(relaxed = true) - private val serverRepository = mockk<ServerRepository>() - private val itemPlaybackDao = mockk<ItemPlaybackDao>() - private val playlistCreator = mockk<PlaylistCreator>() - private val streamChoiceService = mockk<StreamChoiceService>() - private val navigationManager = mockk<NavigationManager>(relaxed = true) - private val userPreferencesService = mockk<UserPreferencesService>() - - private val movie = movie() - private val subtitleUrl = "http://localhost:8096/subtitles/0.srt" - private val videoUrl = "http://localhost:8096/video/stream" - private val mediaSource = - TestTracks - .Builder() - .addExternalSubtitle() - .buildMediaSourceInfo() - .let { source -> - source.copy( - id = "source-id", - runTimeTicks = 90.minutes.inWholeTicks, - mediaStreams = - source.mediaStreams!!.map { - it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English") - }, - ) - } - private val subtitle = mediaSource.mediaStreams!!.single() - private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity") - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - WholphinDispatchers.configure(testDispatcher) - mockkStatic(::getExternalPlayers) - every { getExternalPlayers(context) } returns - listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString())) - every { api.userLibraryApi } returns userLibraryApi - every { api.videosApi } returns videosApi - every { api.subtitleApi } returns subtitleApi - every { api.playStateApi } returns playStateApi - every { serverRepository.currentUser } returns null - coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) - coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns - StreamChoiceResult(subtitle, StreamChoiceReason.Unknown) - coEvery { streamChoiceService.getSeriesTrackChoices(any<BaseItemDto>(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList() - every { - videosApi.getVideoStreamUrl( - itemId = movie.id, - mediaSourceId = mediaSource.id, - static = true, - ) - } returns videoUrl - every { - subtitleApi.getSubtitleUrl( - routeItemId = any(), - routeMediaSourceId = mediaSource.id!!, - routeIndex = subtitle.index, - routeFormat = "srt", - ) - } returns subtitleUrl - setupPlayer("") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - unmockkStatic(::getExternalPlayers) - WholphinDispatchers.reset() - } - - private fun setupPlayer(playerId: String) { - val preferences = - AppPreferences.getDefaultInstance().updatePlaybackPreferences { - externalPlayer = playerId - } - coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null) - } - - private fun createIntent(destination: Destination): Intent { - val viewModel = - PlayExternalViewModel( - SavedStateHandle(), - context, - api, - serverRepository, - itemPlaybackDao, - playlistCreator, - streamChoiceService, - navigationManager, - userPreferencesService, - ) - viewModel.init(destination) - testDispatcher.scheduler.advanceUntilIdle() - assertEquals(LoadingState.Success, viewModel.state.value.loading) - return viewModel.state.value.intent - } - - private fun assertStartFromBeginning(intent: Intent) { - assertEquals(true, intent.extras!!.get("from_start")) - assertEquals(0, intent.extras!!.get("position")) - } - - @Test - fun `Explicit VLC starts from beginning at zero position`() = - runTest(testDispatcher) { - setupPlayer(vlc.flattenToString()) - val intent = createIntent(Destination.Playback(movie.id, 0L)) - assertEquals(vlc, intent.component) - assertStartFromBeginning(intent) - } - - @Test - fun `Implicit player starts VLC from beginning at zero position`() = - runTest(testDispatcher) { - val intent = createIntent(Destination.Playback(movie.id, 0L)) - assertNull(intent.component) - assertStartFromBeginning(intent) - } - - @Test - fun `Unavailable selected player falls back to implicit start from beginning`() = - runTest(testDispatcher) { - setupPlayer("missing.player/missing.player.Activity") - val intent = createIntent(Destination.Playback(movie.id, 0L)) - assertNull(intent.component) - assertStartFromBeginning(intent) - } - - @Test - fun `Explicit VLC preserves positive resume position`() = - runTest(testDispatcher) { - setupPlayer(vlc.flattenToString()) - val intent = createIntent(Destination.Playback(movie.id, 42_000L)) - assertEquals(vlc, intent.component) - assertEquals(false, intent.extras!!.get("from_start")) - assertEquals(42_000, intent.extras!!.get("position")) - } - - @Test - fun `Implicit player preserves positive resume position`() = - runTest(testDispatcher) { - val intent = createIntent(Destination.Playback(movie.id, 42_000L)) - assertNull(intent.component) - assertEquals(false, intent.extras!!.get("from_start")) - assertEquals(42_000, intent.extras!!.get("position")) - } - - @Test - fun `Playback list starts from beginning`() = - runTest(testDispatcher) { - val playlist = playlist() - coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist) - coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns - PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie)))) - - val intent = createIntent(Destination.PlaybackList(playlist.id)) - assertStartFromBeginning(intent) - } - - @Test - fun `VLC starts from beginning without subtitles or duration`() = - runTest(testDispatcher) { - coEvery { streamChoiceService.chooseSource(any(), any()) } returns - mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null) - coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null - - val intent = createIntent(Destination.Playback(movie.id, 0L)) - assertStartFromBeginning(intent) - assertFalse(intent.hasExtra("subtitles_location")) - assertFalse(intent.hasExtra("extra_duration")) - assertFalse(intent.hasExtra("subs.enable")) - assertFalse(intent.hasExtra("forcedsrt")) - } - - @Test - fun `Existing player extras and playback-start reporting remain unchanged`() = - runTest(testDispatcher) { - val intent = createIntent(Destination.Playback(movie.id, 42_000L)) - val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}" - val subtitleUri = Uri.parse(subtitleUrl) - assertEquals(Intent.ACTION_VIEW, intent.action) - assertEquals(Uri.parse(videoUrl), intent.data) - assertEquals("video/*", intent.type) - assertEquals(title, intent.getStringExtra("title")) - assertEquals(42_000, intent.extras!!.get("position")) - assertTrue(intent.getBooleanExtra("return_result", false)) - assertTrue(intent.getBooleanExtra("secure_uri", false)) - assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs")) - assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name")) - assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable")) - assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location")) - assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration")) - assertEquals(42_000, intent.extras!!.get("startfrom")) - assertFalse(intent.getBooleanExtra("forceresume", true)) - assertEquals(title, intent.getStringExtra("forcename")) - assertEquals(subtitleUri, intent.getParcelableExtra<Uri>("forcedsrt")) - coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) } - coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) } - } -} 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 deleted file mode 100644 index 87e5670a..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ /dev/null @@ -1,916 +0,0 @@ -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.data.model.SeriesTrackChoiceType -import com.github.damontecres.wholphin.data.model.ServerUserConfig -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.StreamChoiceReason -import com.github.damontecres.wholphin.services.StreamChoiceResult -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.BaseItemDto -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.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 = - ServerUserConfig( - id = userId, - name = "test-user", - ) - - 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.getSeriesTrackChoices( - any<BaseItemDto>(), - any<SeriesTrackChoiceType>(), - ) - } returns emptyList() - 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 - StreamChoiceResult( - mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, - StreamChoiceReason.Unknown, - ) - - coEvery { - mockStreamChoiceService.chooseSubtitleStream( - item = any(), - source = mediaSource, - audioStream = any(), - itemPlayback = any(), - stc = any(), - prefs = any(), - ) - } returns - StreamChoiceResult( - mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, - StreamChoiceReason.Unknown, - ) - } - - @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 `Direct play includes the opened live stream ID`() = - runTest(testDispatcher) { - setupPreferences { cinemaMode = false } - val liveStreamId = "opened-live-stream" - val liveSource = mediaSource.copy(liveStreamId = liveStreamId) - coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) - coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns - successResponse( - PlaybackInfoResponse( - mediaSources = listOf(liveSource), - playSessionId = playSessionId, - ), - ) - every { - mockVideosApi.getVideoStreamUrl( - itemId = movie.id, - mediaSourceId = liveSource.id, - liveStreamId = liveSource.liveStreamId, - static = true, - tag = liveSource.eTag, - playSessionId = playSessionId, - ) - } returns videoStreamUrl - - createViewModel(Destination.Playback(movie.id, 0L)) - - val mediaItem = slot<MediaItem>() - verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) } - val playbackUri = mediaItem.captured.localConfiguration?.uri - Assert.assertEquals(videoStreamUrl, playbackUri.toString()) - verify(exactly = 1) { - mockVideosApi.getVideoStreamUrl( - itemId = movie.id, - mediaSourceId = liveSource.id, - liveStreamId = liveStreamId, - static = true, - tag = liveSource.eTag, - playSessionId = playSessionId, - ) - } - } - - @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/ui/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt deleted file mode 100644 index 6a6037f9..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import org.junit.Assert.assertEquals -import org.junit.Test - -class SeekAccelerationTest { - @Test - fun returnsOneWhenNotRepeating() { - assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) - } - - @Test - fun unknownDurationDoesNotAccelerate() { - assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) - assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) - } - - @Test - fun shortContentHasTwoTiers() { - val shortDurationMs = 20L * 60_000L - - assertEquals( - 1, - calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), - ) - assertEquals( - 2, - calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), - ) - } - - @Test - fun mediumContentEscalatesAcrossAllTiers() { - val mediumDurationMs = 60L * 60_000L - - assertEquals( - 1, - calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), - ) - assertEquals( - 2, - calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), - ) - assertEquals( - 3, - calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), - ) - assertEquals( - 4, - calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), - ) - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt deleted file mode 100644 index a6a23244..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.damontecres.wholphin.ui.setup - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.util.UUID - -@Config(sdk = [34]) -@RunWith(RobolectricTestRunner::class) -class UserListFocusTest { - @get:Rule val composeTestRule = createComposeRule() - - private val serverId = UUID.fromString("00000000-0000-0000-0000-000000000010") - private val users = - listOf("Ada", "Bert", "Cleo").mapIndexed { index, name -> - JellyfinUserAndImage( - user = - JellyfinUser( - id = UUID(0, (index + 1).toLong()), - name = name, - serverId = serverId, - accessToken = "token", - ), - imageUrl = null, - known = true, - ) - } - - private fun showUsers(currentUser: JellyfinUser?) { - composeTestRule.setContent { - WholphinTheme { - UserList( - users = users, - currentUser = currentUser, - onSwitchUser = {}, - onAddUser = {}, - onRemoveUser = {}, - onSwitchServer = {}, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - - @Test - fun `opening selector focuses active profile and supports D-pad navigation`() { - showUsers(users[1].user) - - composeTestRule.onNodeWithText("B").assertIsFocused().performKeyInput { - pressKey(Key.DirectionRight) - } - composeTestRule.onNodeWithText("C").assertIsFocused() - } - - @Test - fun `opening selector scrolls to an active profile outside the initial viewport`() { - showUsers(users[2].user) - - composeTestRule.onNodeWithText("C").assertIsFocused() - } - - @Test - fun `opening selector without a listed active profile focuses first profile`() { - showUsers(null) - - composeTestRule.onNodeWithText("A").assertIsFocused() - } - - @Test - fun `opening selector with a removed active profile focuses first profile`() { - showUsers(users[1].user.copy(id = UUID(0, 99))) - - composeTestRule.onNodeWithText("A").assertIsFocused() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt deleted file mode 100644 index 8175371e..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilterChainTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.github.damontecres.wholphin.util - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.nio.ByteBuffer - -class BitstreamFilterChainTest { - @Test - fun keepsTheSizeWhenThereIsNoFilter() { - assertEquals(DATA.size, applyBitstreamFilters(emptyList(), buffer(), 0, DATA.size)) - } - - @Test - fun keepsTheSizeOfAnInPlaceFilter() { - val inPlace = - BitstreamFilter { data, offset, size -> - data.put(offset, 0xFF.toByte()) - size - } - val target = buffer() - - val size = applyBitstreamFilters(listOf(inPlace), target, 0, DATA.size) - - assertEquals(DATA.size, size) - assertArrayEquals(byteArrayOf(0xFF.toByte()) + DATA.copyOfRange(1, DATA.size), target.array()) - } - - @Test - fun reportsTheShorterSizeOfAFilterWhichDropsBytes() { - // What a Dolby Vision profile 7 to 8.1 conversion does: it removes the enhancement layer, - // compacts what it keeps and comes out shorter than it went in. The buffer is direct, - // like the decoder input buffer a filter is handed on a device: it has no backing array, - // so the bytes are moved with absolute gets and puts. - val dropsTheThirdAndFourthBytes = - BitstreamFilter { data, offset, size -> - for (i in 2 until size - 2) { - data.put(offset + i, data.get(offset + i + 2)) - } - size - 2 - } - val target = directBuffer() - - val size = applyBitstreamFilters(listOf(dropsTheThirdAndFourthBytes), target, 0, DATA.size) - - assertFalse("the test should exercise a buffer without a backing array", target.hasArray()) - assertEquals(DATA.size - 2, size) - assertEquals(listOf<Byte>(1, 2, 5, 6, 7, 8), (0 until size).map { target.get(it) }) - } - - @Test - fun handsTheNextFilterTheSizeReportedByThePreviousOne() { - val seen = mutableListOf<Int>() - val dropTwo = - BitstreamFilter { _, _, size -> - seen.add(size) - size - 2 - } - val dropOne = - BitstreamFilter { _, _, size -> - seen.add(size) - size - 1 - } - - val size = applyBitstreamFilters(listOf(dropTwo, dropOne), buffer(), 0, DATA.size) - - assertEquals(listOf(DATA.size, DATA.size - 2), seen) - assertEquals(DATA.size - 3, size) - } - - @Test - fun ignoresAFilterWhichReportsMoreBytesThanItWasGiven() { - val seen = mutableListOf<Int>() - val tooLong = BitstreamFilter { _, _, size -> size + 1 } - val recording = - BitstreamFilter { _, _, size -> - seen.add(size) - size - } - - val size = applyBitstreamFilters(listOf(tooLong, recording), buffer(), 0, DATA.size) - - assertEquals(DATA.size, size) - assertEquals(listOf(DATA.size), seen) - } - - @Test - fun ignoresAFilterWhichReportsANegativeSize() { - val negative = BitstreamFilter { _, _, _ -> -1 } - - assertEquals(DATA.size, applyBitstreamFilters(listOf(negative), buffer(), 0, DATA.size)) - } - - @Test - fun stopsOnceNothingIsLeftOfTheAccessUnit() { - var called = false - val dropsEverything = BitstreamFilter { _, _, _ -> 0 } - val next = - BitstreamFilter { _, _, size -> - called = true - size - } - - val size = applyBitstreamFilters(listOf(dropsEverything, next), buffer(), 0, DATA.size) - - assertEquals(0, size) - assertTrue("a filter should not be handed an empty access unit", !called) - } - - @Test - fun filtersAreGivenTheOffsetTheyWereCalledWith() { - val offsets = mutableListOf<Int>() - val recording = - BitstreamFilter { _, offset, size -> - offsets.add(offset) - size - } - - applyBitstreamFilters(listOf(recording, recording), buffer(), 2, DATA.size - 2) - - assertEquals(listOf(2, 2), offsets) - } - - companion object { - private val DATA = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) - - private fun buffer() = ByteBuffer.wrap(DATA.copyOf()) - - private fun directBuffer() = - ByteBuffer.allocateDirect(DATA.size).also { buffer -> - DATA.forEachIndexed { index, byte -> buffer.put(index, byte) } - } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt deleted file mode 100644 index 3c8fdd23..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/BitstreamFilteringCodecAdapterTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import android.media.MediaCodec -import androidx.annotation.OptIn -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Test -import java.nio.ByteBuffer - -/** - * Covers the hand off to the decoder: the adapter has to queue the size the filters report, not - * the size it was called with, and it has to leave the buffers no filter may touch alone. - */ -class BitstreamFilteringCodecAdapterTest { - private val delegate = mockk<MediaCodecAdapter>(relaxed = true) - private val buffer = ByteBuffer.allocateDirect(SIZE) - - @Test - fun queuesTheSizeTheFilterReports() { - every { delegate.getInputBuffer(INDEX) } returns buffer - val adapter = adapterWith(BitstreamFilter { _, _, size -> size - 2 }) - - adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) - - verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE - 2, PRESENTATION_TIME_US, 0) } - } - - @Test - fun queuesTheSizeItWasGivenWhenTheFilterLeavesTheAccessUnitAlone() { - every { delegate.getInputBuffer(INDEX) } returns buffer - val adapter = adapterWith(BitstreamFilter { _, _, size -> size }) - - adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) - - verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) } - } - - @Test - fun leavesCodecConfigBuffersAlone() { - every { delegate.getInputBuffer(INDEX) } returns buffer - val filter = RecordingFilter() - val adapter = adapterWith(filter) - - adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG) - - assertEquals(0, filter.calls) - verify { - delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG) - } - } - - @Test - fun leavesEmptyBuffersAlone() { - every { delegate.getInputBuffer(INDEX) } returns buffer - val filter = RecordingFilter() - val adapter = adapterWith(filter) - - adapter.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0) - - assertEquals(0, filter.calls) - verify { delegate.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0) } - } - - @Test - fun queuesTheSizeItWasGivenWhenThereIsNoInputBuffer() { - every { delegate.getInputBuffer(INDEX) } returns null - val filter = RecordingFilter() - val adapter = adapterWith(filter) - - adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) - - assertEquals(0, filter.calls) - verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) } - } - - private fun adapterWith(filter: BitstreamFilter) = BitstreamFilteringCodecAdapter(delegate, listOf(filter)) - - private class RecordingFilter : BitstreamFilter { - var calls = 0 - - override fun filter( - data: ByteBuffer, - offset: Int, - size: Int, - ): Int { - calls++ - return size - } - } - - companion object { - private const val INDEX = 3 - private const val OFFSET = 1 - private const val SIZE = 8 - private const val PRESENTATION_TIME_US = 1234L - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt deleted file mode 100644 index 483fa3ef..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.media3.common.DrmInitData -import androidx.media3.common.Format -import androidx.media3.common.MimeTypes -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue -import org.junit.Test - -class DolbyVisionProfile7ConversionTest { - @Test - fun recognisesProfile7InBothSampleEntryForms() { - assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06"))) - assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06"))) - } - - @Test - fun doesNotMistakeOtherProfilesOrCodecsForProfile7() { - assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06"))) - assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06"))) - assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null))) - assertFalse( - DolbyVisionProfile7Conversion.isProfile7( - Format - .Builder() - .setSampleMimeType(MimeTypes.VIDEO_H265) - .setCodecs("dvhe.07.06") - .build(), - ), - ) - } - - @Test - fun rewritesOnlyTheProfileOfTheCodecString() { - assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06")) - assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09")) - } - - @Test - fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() { - val format = dolbyVision("dvhe.07.06") - - val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) - - assertEquals("dvhe.08.06", advertised.codecs) - assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType) - assertEquals(format.width, advertised.width) - assertEquals(format.height, advertised.height) - assertEquals(format.initializationData, advertised.initializationData) - } - - @Test - fun leavesTracksWhichAreNotProfile7AsTheSameInstance() { - val profile8 = dolbyVision("dvhe.08.06") - val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build() - - assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8)) - assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc)) - } - - @Test - fun leavesAnEncryptedProfile7TrackAlone() { - // Encrypted samples take the secure path, which the bitstream filter does not touch - val encrypted = - dolbyVision("dvhe.07.06") - .buildUpon() - .setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1)))) - .build() - - assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted)) - } - - private companion object { - fun dolbyVision(codecs: String?): Format = - Format - .Builder() - .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) - .setCodecs(codecs) - .setWidth(3840) - .setHeight(2160) - .setInitializationData(listOf(byteArrayOf(0x01, 0x02))) - .build() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt deleted file mode 100644 index d888be15..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -@file:OptIn(markerClass = [UnstableApi::class]) - -package com.github.damontecres.wholphin.util - -import androidx.annotation.OptIn -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.MimeTypes -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.TrackOutput -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame -import org.junit.Test - -/** - * The factory has to reach the video track's format through three layers of media3 forwarding, - * rewrite it there and nowhere else, and let sample data through untouched. - */ -class DolbyVisionProfile7ExtractorsFactoryTest { - private val videoTrack = mockk<TrackOutput>(relaxed = true) - private val audioTrack = mockk<TrackOutput>(relaxed = true) - private val output = - mockk<ExtractorOutput>(relaxed = true) { - every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack - every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack - } - private val extractor = mockk<Extractor>(relaxed = true) - private val delegate = - mockk<ExtractorsFactory> { - every { createExtractors() } returns arrayOf(extractor) - } - - @Test - fun advertisesTheVideoTrackAsProfile8() { - val video = initialisedVideoTrack() - - video.format(dolbyVision("dvhe.07.06")) - - val published = slot<Format>() - verify { videoTrack.format(capture(published)) } - assertEquals("dvhe.08.06", published.captured.codecs) - } - - @Test - fun leavesOtherVideoFormatsAsTheyAre() { - val video = initialisedVideoTrack() - val hevc = - Format - .Builder() - .setSampleMimeType(MimeTypes.VIDEO_H265) - .setCodecs("hvc1.2.4.L153") - .build() - - video.format(hevc) - - verify { videoTrack.format(hevc) } - } - - @Test - fun leavesTheAudioTrackUnwrapped() { - val wrapped = wrappedOutput() - - assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO)) - } - - @Test - fun passesSampleDataStraightThrough() { - val video = initialisedVideoTrack() - val data = ParsableByteArray(byteArrayOf(1, 2, 3)) - - video.sampleData(data, 3) - video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) - - verify { videoTrack.sampleData(data, 3) } - verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) } - } - - @Test - fun wrapsEveryExtractorOfTheDelegate() { - val another = mockk<Extractor>(relaxed = true) - every { delegate.createExtractors() } returns arrayOf(extractor, another) - - val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() - - assertEquals(2, extractors.size) - extractors[1].init(output) - verify { another.init(any()) } - } - - private fun wrappedOutput(): ExtractorOutput { - val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() - extractors.single().init(output) - val wrapped = slot<ExtractorOutput>() - verify { extractor.init(capture(wrapped)) } - return wrapped.captured - } - - private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO) - - private companion object { - const val VIDEO_ID = 1 - const val AUDIO_ID = 2 - - fun dolbyVision(codecs: String): Format = - Format - .Builder() - .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) - .setCodecs(codecs) - .build() - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt deleted file mode 100644 index b5c6189c..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt +++ /dev/null @@ -1,293 +0,0 @@ -package com.github.damontecres.wholphin.util - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Test -import java.nio.ByteBuffer - -/** - * The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under - * test is everything around it: which access units are touched at all, that the enhancement layer - * goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as - * it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer. - */ -class DolbyVisionProfile7FilterTest { - @Test - fun dropsTheEnhancementLayerAndRewritesTheRpu() { - val filter = filterWith(FakeRewriter(profile = 7)) - val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU)) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) - } - - @Test - fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() { - // A dual layer remux whose enhancement layer lives in Matroska block additions, which the - // extractor discards: the RPU still needs its rewrite for the profile 8 decoder - val filter = filterWith(FakeRewriter(profile = 7)) - val target = directBuffer(annexB(VCL, RPU)) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) - } - - @Test - fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() { - val filter = filterWith(FakeRewriter(profile = 7)) - val accessUnit = annexB(VCL, EL, RPU) - val prefix = byteArrayOf(0x7A, 0x7B) - val suffix = byteArrayOf(0x7C) - val target = directBuffer(prefix + accessUnit + suffix) - - val size = filter.filter(target, prefix.size, accessUnit.size) - - assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size)) - assertEquals(prefix.toList(), target.bytes(0, prefix.size)) - assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size)) - } - - @Test - fun doesNotMoveThePositionOrLimitOfTheBuffer() { - val filter = filterWith(FakeRewriter(profile = 7)) - val target = directBuffer(annexB(VCL, EL, RPU)) - target.position(3) - target.limit(target.capacity() - 1) - - filter.filter(target, 0, target.capacity()) - - assertEquals(3, target.position()) - assertEquals(target.capacity() - 1, target.limit()) - } - - @Test - fun leavesAnAccessUnitWithoutAnRpuAlone() { - val rewriter = FakeRewriter(profile = 7) - val filter = filterWith(rewriter) - val accessUnit = annexB(VPS, VCL, EL) - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, size) - assertEquals(accessUnit.toList(), target.bytes(0, size)) - assertEquals(0, rewriter.conversions) - } - - @Test - fun leavesAnRpuOfAnotherProfileAlone() { - val rewriter = FakeRewriter(profile = 8) - val filter = filterWith(rewriter) - val accessUnit = annexB(VCL, RPU) - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, size) - assertEquals(accessUnit.toList(), target.bytes(0, size)) - assertEquals(0, rewriter.conversions) - } - - @Test - fun leavesAnAccessUnitAloneWhenTheRewriteThrows() { - val filter = filterWith(FakeRewriter(profile = 7, throws = true)) - val accessUnit = annexB(VCL, EL, RPU) - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, size) - assertEquals(accessUnit.toList(), target.bytes(0, size)) - } - - @Test - fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() { - // No enhancement layer to make room, and an RPU which comes out longer than it went in - val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 })) - val accessUnit = annexB(VCL, RPU) - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, size) - assertEquals(accessUnit.toList(), target.bytes(0, size)) - } - - @Test - fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() { - val longerRpu = RPU + byteArrayOf(0x55, 0x56) - val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu)) - val target = directBuffer(annexB(VCL, EL, RPU)) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size)) - } - - @Test - fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() { - val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1)) - val accessUnit = annexB(VCL, EL, RPU) - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, size) - assertEquals(accessUnit.toList(), target.bytes(0, size)) - } - - @Test - fun leavesDataWhichIsNotAnnexBAlone() { - val rewriter = FakeRewriter(profile = 7) - val filter = filterWith(rewriter) - val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C) - val target = directBuffer(data) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals(data.size, size) - assertEquals(data.toList(), target.bytes(0, size)) - assertEquals(0, rewriter.conversions) - } - - @Test - fun walksThreeByteStartCodesToo() { - val filter = filterWith(FakeRewriter(profile = 7)) - val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU - val target = directBuffer(accessUnit) - - val size = filter.filter(target, 0, target.capacity()) - - assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size)) - } - - @Test - fun givesUpForGoodWhenTheNativeLibraryIsMissing() { - var attempts = 0 - val filter = - DolbyVisionProfile7Filter { - attempts++ - throw UnsatisfiedLinkError("no libdovi_android for this ABI") - } - val accessUnit = annexB(VCL, EL, RPU) - val target = directBuffer(accessUnit) - - val first = filter.filter(target, 0, target.capacity()) - val second = filter.filter(target, 0, target.capacity()) - - assertEquals(accessUnit.size, first) - assertEquals(accessUnit.size, second) - assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size)) - assertEquals("the library should be tried once, not on every access unit", 1, attempts) - } - - @Test - fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() { - val rewriter = FakeRewriter(profile = 7) - val filter = filterWith(rewriter) - val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL)) - - filter.filter(target, 0, target.capacity()) - - assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame) - } - - /** - * Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the - * Annex B start codes, and swaps its payload for [converted], moving whatever follows. - */ - private class FakeRewriter( - private val profile: Int, - private val converted: ByteArray = CONVERTED_RPU, - private val throws: Boolean = false, - private val reportedSize: Int? = null, - ) : RpuRewriter { - var conversions = 0 - var lastFrame: List<Byte> = emptyList() - - override fun profileOf( - frame: ByteBuffer, - size: Int, - ): Int { - assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray()) - return profile - } - - override fun convertToProfile8( - frame: ByteBuffer, - size: Int, - ): Int { - conversions++ - lastFrame = frame.bytes(0, size) - if (throws) { - throw IllegalArgumentException("Unable to parse Dolby Vision RPU") - } - val bytes = frame.bytes(0, size).toByteArray() - val rpuStart = indexOfNal(bytes, RPU) - val rpuEnd = nextStartCode(bytes, rpuStart + 1) - val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size) - check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" } - rewritten.forEachIndexed { index, byte -> frame.put(index, byte) } - return reportedSize ?: rewritten.size - } - - private fun indexOfNal( - bytes: ByteArray, - nal: ByteArray, - ): Int { - for (i in 0..bytes.size - nal.size) { - if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) { - return i - } - } - throw AssertionError("RPU not found in the frame handed to the rewriter") - } - - private fun nextStartCode( - bytes: ByteArray, - from: Int, - ): Int { - for (i in from..bytes.size - 3) { - if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) { - return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i - } - } - return bytes.size - } - } - - companion object { - private val START_CODE = byteArrayOf(0, 0, 0, 1) - private val SHORT_START_CODE = byteArrayOf(0, 0, 1) - - /** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */ - private fun nal( - type: Int, - vararg payload: Byte, - ): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload - - private val VPS = nal(32, 0x0C, 0x01, 0x7F) - private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44) - private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E) - private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07) - private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08) - - private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } - - private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter } - - /** Like the decoder input buffer on a device: direct, with no backing array. */ - private fun directBuffer(data: ByteArray): ByteBuffer = - ByteBuffer.allocateDirect(data.size).also { buffer -> - data.forEachIndexed { index, byte -> buffer.put(index, byte) } - assertFalse(buffer.hasArray()) - } - - private fun ByteBuffer.bytes( - from: Int, - count: Int, - ): List<Byte> = (from until from + count).map { get(it) } - } -} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt deleted file mode 100644 index f3f75193..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/Hdr10PlusSeiFilterTest.kt +++ /dev/null @@ -1,277 +0,0 @@ -package com.github.damontecres.wholphin.util - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Test -import java.nio.ByteBuffer - -class Hdr10PlusSeiFilterTest { - @Test - fun masksSoleHdr10PlusMessage() { - val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - // Payload type byte follows the start code and the 2 byte NAL unit header - val payloadTypeIndex = START_CODE.size + 2 - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun masksWithThreeByteStartCodes() { - val accessUnit = - SHORT_START_CODE + seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)) + SHORT_START_CODE + vclNal() - val payloadTypeIndex = SHORT_START_CODE.size + 2 - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun masksOnlyHdr10PlusInMultiMessageNal() { - val masteringDisplay = seiMessage(137, MASTERING_DISPLAY_PAYLOAD) - val contentLightLevel = seiMessage(144, CONTENT_LIGHT_LEVEL_PAYLOAD) - val accessUnit = - annexB( - seiNal(masteringDisplay, seiMessage(4, HDR10_PLUS_PAYLOAD), contentLightLevel), - vclNal(), - ) - val payloadTypeIndex = START_CODE.size + 2 + masteringDisplay.size - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun skipsEmulationPreventionBytesInPrecedingPayload() { - // The escaped form of this payload is longer than its payload size, so naive byte - // counting would misparse the HDR10+ message that follows it - val zeroHeavyMessage = seiMessage(137, byteArrayOf(0, 0, 0, 0, 1)) - val accessUnit = - annexB(seiNal(zeroHeavyMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - val payloadTypeIndex = START_CODE.size + 2 + escape(zeroHeavyMessage).size - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun handlesMultiBytePayloadType() { - val bigTypeMessage = seiMessage(300, byteArrayOf(0x42)) - val accessUnit = - annexB(seiNal(bigTypeMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - val payloadTypeIndex = START_CODE.size + 2 + bigTypeMessage.size - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun ignoresOtherItuTT35Messages() { - val accessUnit = annexB(seiNal(seiMessage(4, CEA_708_PAYLOAD)), vclNal()) - - val (masked, result) = filter(accessUnit) - - assertEquals(0, masked) - assertArrayEquals(accessUnit, result) - } - - @Test - fun ignoresSeiNalUnitsAfterTheVclNalUnit() { - val accessUnit = annexB(vclNal(), seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD))) - - val (masked, result) = filter(accessUnit) - - assertEquals(0, masked) - assertArrayEquals(accessUnit, result) - } - - @Test - fun masksAcrossMultipleNonVclNalUnits() { - val accessUnit = - annexB( - audNal(), - seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)), - seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), - vclNal(), - ) - val payloadTypeIndex = - START_CODE.size + audNal().size + - START_CODE.size + seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)).size + - START_CODE.size + 2 - - val (masked, result) = filter(accessUnit) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result) - } - - @Test - fun leavesLengthPrefixedSamplesUntouched() { - val nal = seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)) - val accessUnit = - byteArrayOf( - (nal.size ushr 24).toByte(), - (nal.size ushr 16).toByte(), - (nal.size ushr 8).toByte(), - nal.size.toByte(), - ) + nal - - val (masked, result) = filter(accessUnit) - - assertEquals(0, masked) - assertArrayEquals(accessUnit, result) - } - - @Test - fun leavesTruncatedSeiNalUnitUntouched() { - // Payload size claims more bytes than the NAL unit contains - val brokenNal = byteArrayOf(0x4E, 0x01, 0x04, 0x30, 0xB5.toByte(), 0x00, 0x3C) - val accessUnit = annexB(brokenNal, vclNal()) - - val (masked, result) = filter(accessUnit) - - assertEquals(0, masked) - assertArrayEquals(accessUnit, result) - } - - @Test - fun leavesShortItuTT35MessageUntouched() { - val accessUnit = annexB(seiNal(seiMessage(4, byteArrayOf(0xB5.toByte(), 0x00, 0x3C))), vclNal()) - - val (masked, result) = filter(accessUnit) - - assertEquals(0, masked) - assertArrayEquals(accessUnit, result) - } - - @Test - fun respectsOffsetAndSize() { - val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - val prefix = byteArrayOf(0x55, 0x66) - val data = prefix + accessUnit - val payloadTypeIndex = prefix.size + START_CODE.size + 2 - - val buffer = ByteBuffer.wrap(data.copyOf()) - val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, prefix.size, accessUnit.size) - - assertEquals(1, masked) - assertArrayEquals(maskTypeByte(data, payloadTypeIndex), buffer.array()) - } - - @Test - fun masksMultipleHdr10PlusMessages() { - val firstMessage = seiMessage(4, HDR10_PLUS_PAYLOAD) - val accessUnit = - annexB(seiNal(firstMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - val expected = - maskTypeByte( - maskTypeByte(accessUnit, START_CODE.size + 2), - START_CODE.size + 2 + firstMessage.size, - ) - - val (masked, result) = filter(accessUnit) - - assertEquals(2, masked) - assertArrayEquals(expected, result) - } - - @Test - fun maskingFilterKeepsTheAccessUnitSize() { - val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal()) - val buffer = ByteBuffer.wrap(accessUnit.copyOf()) - - val size = Hdr10PlusMaskingFilter().filter(buffer, 0, accessUnit.size) - - assertEquals(accessUnit.size, size) - assertArrayEquals(maskTypeByte(accessUnit, START_CODE.size + 2), buffer.array()) - } - - companion object { - private val START_CODE = byteArrayOf(0, 0, 0, 1) - private val SHORT_START_CODE = byteArrayOf(0, 0, 1) - - /** ITU-T T.35 header for SMPTE ST 2094-40 plus a few opaque payload bytes */ - private val HDR10_PLUS_PAYLOAD = - byteArrayOf(0xB5.toByte(), 0x00, 0x3C, 0x00, 0x01, 0x04, 0x01, 0x12, 0x34) - - /** ITU-T T.35 with the ATSC provider code (0x0031), as used for CEA-708 captions */ - private val CEA_708_PAYLOAD = - byteArrayOf(0xB5.toByte(), 0x00, 0x31, 0x47, 0x41, 0x39, 0x34, 0x03) - - private val MASTERING_DISPLAY_PAYLOAD = ByteArray(24) { (it + 1).toByte() } - - private val CONTENT_LIGHT_LEVEL_PAYLOAD = byteArrayOf(0x03, 0xE8.toByte(), 0x01, 0xF4.toByte()) - - /** Encodes an SEI message with the ff-extended payload type and size coding of D.2.1 */ - private fun seiMessage( - payloadType: Int, - payload: ByteArray, - ): ByteArray { - val out = mutableListOf<Byte>() - var type = payloadType - while (type >= 255) { - out.add(0xFF.toByte()) - type -= 255 - } - out.add(type.toByte()) - var size = payload.size - while (size >= 255) { - out.add(0xFF.toByte()) - size -= 255 - } - out.add(size.toByte()) - payload.forEach { out.add(it) } - return out.toByteArray() - } - - /** Inserts emulation prevention bytes as required by H.265 section 7.4.2 */ - private fun escape(rbsp: ByteArray): ByteArray { - val out = mutableListOf<Byte>() - var zeroRun = 0 - for (b in rbsp) { - if (zeroRun >= 2 && (b.toInt() and 0xFF) <= 0x03) { - out.add(0x03) - zeroRun = 0 - } - out.add(b) - zeroRun = if (b == 0.toByte()) zeroRun + 1 else 0 - } - return out.toByteArray() - } - - /** Builds a prefix SEI NAL unit (type 39) containing the given messages */ - private fun seiNal(vararg messages: ByteArray): ByteArray { - val rbsp = messages.fold(byteArrayOf()) { acc, message -> acc + message } + 0x80.toByte() - return byteArrayOf(0x4E, 0x01) + escape(rbsp) - } - - /** A minimal VCL NAL unit (type 1, TRAIL_R) */ - private fun vclNal(): ByteArray = byteArrayOf(0x02, 0x01, 0x11, 0x22, 0x33) - - /** A minimal access unit delimiter NAL unit (type 35) */ - private fun audNal(): ByteArray = byteArrayOf(0x46, 0x01, 0x50) - - private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } - - private fun maskTypeByte( - data: ByteArray, - index: Int, - ): ByteArray = data.copyOf().also { it[index] = 0xFE.toByte() } - - private fun filter(data: ByteArray): Pair<Int, ByteArray> { - val buffer = ByteBuffer.wrap(data.copyOf()) - val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, 0, data.size) - return masked to buffer.array() - } - } -} 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 deleted file mode 100644 index daeb934d..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/WholphinDispatchersTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt deleted file mode 100644 index 491280df..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.github.damontecres.wholphin.util.profile - -import io.mockk.every -import io.mockk.mockk -import org.jellyfin.sdk.model.api.CodecType -import org.jellyfin.sdk.model.api.DeviceProfile -import org.jellyfin.sdk.model.api.ProfileConditionValue -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** - * Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own - * codec-capability detection when building the unsupported video range type list. - */ -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, sdk = [30]) -class TestDeviceProfileUtils { - private fun unsupportedRangeTypes( - profile: DeviceProfile, - codec: String, - ): Set<String> = - profile.codecProfiles - .filter { it.type == CodecType.VIDEO && it.codec == codec } - .firstNotNullOf { codecProfile -> - codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value - }.split("|") - .toSet() - - private fun buildProfile( - mediaTest: MediaCodecCapabilitiesTest, - jellyfinTenEleven: Boolean = false, - decodeAv1: Boolean = false, - forceEnabledHdr: Set<String> = emptySet(), - forceDisabledHdr: Set<String> = emptySet(), - ): DeviceProfile = - createDeviceProfile( - mediaTest = mediaTest, - maxBitrate = 100_000_000, - isAC3Enabled = true, - downMixAudio = false, - assDirectPlay = true, - pgsDirectPlay = true, - decodeAv1 = decodeAv1, - jellyfinTenEleven = jellyfinTenEleven, - preferAc3ForSurround = false, - forceEnabledHdr = forceEnabledHdr, - forceDisabledHdr = forceDisabledHdr, - maxResolution = 0, - ) - - @Test - fun `Test HEVC Profile 7 unsupported when device does not support it`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsHevcDolbyVisionEL() } returns false - every { mediaTest.supportsHevcDolbyVision() } returns true - every { mediaTest.supportsHevcHDR10Plus() } returns true - - val profile = buildProfile(mediaTest, jellyfinTenEleven = true) - Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) - } - - @Test - fun `Test force enable overrides HEVC Profile 7 device support check`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsHevcDolbyVisionEL() } returns false - every { mediaTest.supportsHevcDolbyVision() } returns true - every { mediaTest.supportsHevcHDR10Plus() } returns true - - val profile = - buildProfile( - mediaTest, - jellyfinTenEleven = true, - forceEnabledHdr = - setOf( - WholphinVideoRangeType.DOVI_WITH_EL.serialName, - WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, - ), - ) - Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) - } - - @Test - fun `Test force disable overrides HEVC Profile 7 device support check`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsHevcDolbyVisionEL() } returns true - every { mediaTest.supportsHevcDolbyVision() } returns true - every { mediaTest.supportsHevcHDR10Plus() } returns true - - val profile = - buildProfile( - mediaTest, - jellyfinTenEleven = true, - forceDisabledHdr = - setOf( - WholphinVideoRangeType.DOVI_WITH_EL.serialName, - WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, - ), - ) - Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) - } - - @Test - fun `Test AV1 Profile 5 unsupported when device does not support it`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsAV1DolbyVision() } returns false - - val profile = buildProfile(mediaTest, decodeAv1 = false) - Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) - } - - @Test - fun `Test force enable overrides AV1 Profile 5 device support check`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsAV1DolbyVision() } returns false - - val profile = - buildProfile( - mediaTest, - decodeAv1 = false, - forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), - ) - Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) - } - - @Test - fun `Test force disable overrides AV1 Profile 5 device support check`() { - val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true) - every { mediaTest.supportsAV1DolbyVision() } returns true - - val profile = - buildProfile( - mediaTest, - decodeAv1 = false, - forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), - ) - Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt deleted file mode 100644 index 35e45892..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.ui.successQueryResult -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.UserViewsApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class AnimeAvailabilityTest { - private var previousFixtures = false - - @Before fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) - ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>()) - previousFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - } - - @After fun tearDown() { - ConductarrPreference.updateFixtures(previousFixtures) - Dispatchers.resetMain() - } - - @Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking { - val context = ApplicationProvider.getApplicationContext<Application>() - for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) { - for (count in listOf(0, 1)) FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token) - conductarr.select("${server.id}:${user.id}", token, fake.url) - conductarr.authenticate() - val identity = conductarr.identity.value!! - val audiences = identity.provider.audiences()!!.filter { - it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty()) - } - val names = audiences.flatMap { it.libraries.orEmpty() }.distinct() - assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty()) - val views = mockk<UserViewsApi>() - val items = mockk<ItemsApi>() - val api = mockk<ApiClient>() - every { api.userViewsApi } returns views - every { api.itemsApi } returns items - coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map { - BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) - }) - val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) } - coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true, - includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles) - val libraries = LibraryReadFacade(api) - val session = WolfskinSession().apply { - state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply { - pickerVisible = false - focusNav(tabs.size) - } - } - val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - try { - requireNotNull(model.onForeground(server, user)).join() - assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) - assertEquals("The avatar must remain focused when Anime appears or disappears", - session.state!!.tabs.size, session.state!!.navIndex) - val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }), - destinationTotals = mapOf("anime" to count)) - assertEquals("An empty Anime audience must not create a Home shelf", count > 0, - buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" }) - } finally { model.viewModelScope.cancel() } - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt deleted file mode 100644 index 1af82b5c..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import androidx.test.core.app.ApplicationProvider -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.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.coEvery -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class AnimeRootRemovalTest { - private var previousFixtures = false - private var previousUrl = "" - - @Before fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) - ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>()) - previousFixtures = ConductarrPreference.fixtures - previousUrl = ConductarrPreference.baseUrl - ConductarrPreference.updateFixtures(false) - } - - @After fun tearDown() { - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - Dispatchers.resetMain() - } - - @Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking { - val title = Destination.Wolfskin("title/tv%3A123") - val person = Destination.Wolfskin("person/456") - val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true, - listOf(title) to false, listOf(title, person) to false) - for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val context = ApplicationProvider.getApplicationContext<Application>() - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns null - var hasTitles = true - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(user.id) } returns emptyMap() - coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles } - val navigation = NavigationManager() - val session = WolfskinSession() - val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - try { - model.load(server, user) - requireNotNull(model.onForeground(server, user)).join() - val shell = requireNotNull(session.state) - assertTrue(shell.person.hasAnime) - shell.key(ShellKey.Ok) - shell.focusNav(shell.tabs.indexOf("anime")) - shell.key(ShellKey.Ok) - assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList()) - for (page in pushed) { - navigation.navigateTo(page) - shell.enterDestination(page.route) - } - if (avatarFocused) shell.focusNav(shell.tabs.size) - - hasTitles = false - requireNotNull(model.onForeground(server, user)).join() - assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs) - assertEquals("Home must replace the missing root, not the current pushed page", - listOf(Destination.Home()) + pushed, navigation.backStack.toList()) - assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route) - assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab) - if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex) - - shell.key(ShellKey.Back) - if (pushed.isEmpty()) { - assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused) - assertEquals("home", shell.route) - assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) - if (!avatarFocused) assertEquals(0, shell.navIndex) - } else { - assertEquals("Back pops only the current page and retains the replacement Home root", - listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList()) - } - } finally { model.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt deleted file mode 100644 index b748367e..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.jellyfin.sdk.model.api.CollectionType -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ConductarrAudienceRecoveryTest { - @Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) } - @After fun restoreMain() { Dispatchers.resetMain() } - - @Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking { - val context = ApplicationProvider.getApplicationContext<Application>() - ConductarrPreference.initialize(context) - ConductarrPreference.updateFixtures(false) - for (empty in listOf(false, true)) FakeConductarr().use { fake -> - fake.audiencesStatus = if (empty) 200 else 503 - fake.emptyAudiences = empty - ConductarrSession(MemoryCacheStore()).use { session -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) - session.authenticate() - assertTrue(session.identity.value!!.signedIn) - val client = session.identity.value!!.client - val libraries = LibraryReadFacade(mockk()) - val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) - try { - requireNotNull(model.onForeground(server, user)).join() - assertNull(libraries.audiences) - assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES)) - assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS)) - assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) - if (!empty) { - fake.audiencesStatus = 200 - requireNotNull(model.onForeground(server, user)).join() - assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries) - assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) - assertSame(client, session.identity.value!!.client) - } - } finally { model.viewModelScope.cancel() } - } - } - } - - @Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking { - val context = ApplicationProvider.getApplicationContext<Application>() - ConductarrPreference.initialize(context) - ConductarrPreference.updateFixtures(false) - val previousUrl = ConductarrPreference.baseUrl - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { session -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) - session.authenticate() - val libraries = LibraryReadFacade(mockk()) - val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) - try { - val identity = session.identity.value!! - val intent = org.justinvisser.wolfskin.design.DiscoverRequest( - org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared") - fake.postStatus = 503 - assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, - identity.provider.operations(identity.operator!!).request(intent).phase) - model.retryConductarr(server, user) - val refreshed = session.identity.value!! - assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed, - refreshed.provider.operations(refreshed.operator!!).request(intent).phase) - assertEquals(1, fake.calls.count { it.method == "POST" }) - } finally { - model.viewModelScope.cancel() - ConductarrPreference.updateBaseUrl(previousUrl) - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt deleted file mode 100644 index c4e63d27..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.File -import java.util.UUID -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrHomeSurfaceTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun currentIdentityFeedsRealHomeAndSavedRequestsWithoutInventingJellyfinPlayback() { - FakeConductarr().use { fake -> - fake.localArt = true - ConductarrSession(MemoryCacheStore()).use { session -> - val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fake", fake.url, null) - val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val home = mockk<HomeReadFacade>() - coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf( - HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) - val cache = mockk<HomeLibraryCache>(relaxed = true) - every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) - try { - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } - rule.setContent { WolfskinTheme { - LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) } - val snapshot = model.snapshot - CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) { - ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } - } - } } - rule.waitUntil(timeoutMillis = 30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } - } - rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" } - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - rule.mainClock.advanceTimeBy(500) - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() - assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held }) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "conductarr-home-real.png").absolutePath) - val provider = session.identity.value!!.provider - runBlocking { provider.requests().last() } - fake.status = 503 - val saved = runBlocking { provider.requests().last() } - assertNotNull(saved.savedAt) - assertEquals(ComingInStage.Downloading, saved.items.single().stage) - assertEquals("Shared", saved.items.single().actor(shell.person)) - rule.runOnIdle { shell.requests.update(saved); shell.openRequests() } - rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() - rule.onRoot().captureRoboImage(File(directory, "conductarr-requests-saved.png").absolutePath) - } finally { model.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt deleted file mode 100644 index b5be3c2b..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* - -/** Consumer-visible mappings exercise realistic wire responses over a local HTTP server. */ -class ConductarrProjectionTest { - @Test fun householdFeedAndCatalogueBecomePersonalRowsWithoutRawLibraryNamesOrHomeComingSoon() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val provider = ConductarrProvider(client) - assertEquals("Shared", provider.profiles()!!.profiles!!.first().name) - assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries) - val snapshot = provider.discover("shared").last() - assertEquals(DiscoverStatus.Ready, snapshot.status) - assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster) - assertEquals("Solaris", snapshot.seeds.single().title) - assertEquals(listOf(42L), snapshot.seeds.single().items.map { it.tmdb }) - assertEquals(listOf("New on Netflix", "Trending"), snapshot.lanes.take(2).map { it.title }) - assertTrue(snapshot.lanes.first { it.id == "genre-Comedy" }.pending) - assertEquals(99L, snapshot.hidden.single().tmdb) - assertEquals(8L, snapshot.services.single().id) - val rows = snapshot.homeRows() - assertEquals("Moon", rows.first { it.id == "pk-movie" }.cards.single().title) - assertFalse(rows.any { "Coming soon" in it.title || "Shared Anime" in it.title }) - assertTrue(fake.calls.filter { it.url.encodedPath == "/catalogue/discover" }.all { it.url.queryParameter("genre_id") == null }) - val expanded = provider.lanesThrough(99) - assertFalse(expanded.any { it.pending }) - assertTrue(fake.calls.any { it.url.queryParameter("genre_id") == "35" }) - assertEquals("Coming soon", expanded.last().title) - val service = provider.service(snapshot.services.single(), "tv") - assertEquals(listOf("Popular series", "New series", "Top rated series"), service.take(3).map { it.title }) - assertTrue(fake.calls.filter { it.url.queryParameter("service_id") == "8" }.any { it.url.queryParameter("media_type") == "tv" }) - val requests = provider.requests().last() - val request = requests.items.single() - assertEquals(ComingInStage.Downloading, request.stage) - assertEquals("Mindel", request.requesterName) - assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) - assertEquals(1, requests.openCount) - } - } - } - - @Test fun uncertainRequestIsLookedUpRatherThanSentTwiceAndSharedRemainsTheAuthenticatedActor() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val operations = ConductarrDiscoverOperations(client, { DiscoverSnapshot() }, profile = "shared", allowedRequesters = setOf("shared", "mindel")) - val intent = DiscoverRequest(DiscoverTitle(42, "movie", "Moon"), "mindel", "untrusted-ui-profile") - fake.postStatus = 503 - assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) - assertEquals(RequestPhase.NotConfirmed, operations.request(intent).phase) - assertEquals(1, fake.calls.count { it.method == "POST" }) - val post = fake.calls.single { it.method == "POST" } - val body = post.body!!.utf8() - assertTrue(body.contains("\"requester\":\"mindel\"")) - assertTrue(body.contains("\"recommendation_profile\":\"shared\"")) - assertFalse(body.contains("actor")) - assertEquals(listOf("GET", "POST", "GET"), fake.calls.map { it.method }) - fake.existingRequests = true - val receipt = operations.check(intent) - assertEquals(RequestPhase.Receipt, receipt.phase) - assertEquals("Shared", receipt.actor) - assertEquals("mindel", receipt.requester) - assertEquals(RequestPhase.Refused, operations.request(intent.copy(requester = "outsider")).phase) - assertEquals(RequestPhase.Refused, operations.request(intent.copy(title = DiscoverTitle(44, "tv", "Dark"))).phase) - assertEquals(1, fake.calls.count { it.method == "POST" }) - assertTrue(operations.hide("untrusted-ui-profile", intent.title)) - assertTrue(operations.restore("untrusted-ui-profile", intent.title)) - assertTrue(fake.calls.takeLast(2).all { "/profiles/shared/dismissals" in it.url.encodedPath }) - } - } - } - - @Test fun episodeVotesReplaceSeriesScoreAndConditionalTitleReadsKeepTheOriginalBody() = runBlocking { - FakeConductarr().use { fake -> - ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> - val ratings = ConductarrRatings(client) - assertEquals(listOf("rtCritics", "rtAudience", "imdb"), ratings.read(42, "movie", null, null)!!.map { it.source }) - val episode = ratings.read(44, "episode", 1, 1)!! - assertEquals(listOf("rtCritics", "rtAudience", "tmdb"), episode.map { it.source }) - assertTrue(episode.last().episode) - assertEquals(8.1, episode.last().score, 0.0) - ratings.read(44, "episode", 1, 2) - assertEquals(1, fake.calls.count { it.url.encodedPath.contains("/seasons/") }) - assertFalse(fake.calls.any { it.url.encodedPath.endsWith("/enrichment") }) - val provider: ConductarrTitleProvider = ConductarrProvider(client) - val title = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved - fake.notModified = true - val revalidated = (provider.detail(42, MediaType.MOVIE).last() as ReadResult.Value).saved - assertEquals(title.value, revalidated.value) - assertEquals(title.savedAtMillis, revalidated.savedAtMillis) - assertEquals("\"wire-v1\"", fake.calls.last().headers["If-None-Match"]) - fake.notModified = false - fake.ratingsStatus = 401 - assertEquals(emptyList<LibraryRating>(), ratings.read(45, "movie", null, null)) - assertEquals(emptyList<LibraryRating>(), ratings.read(42, "movie", null, null)) - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt deleted file mode 100644 index 3027d5fc..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.nio.file.Files -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* - -/** Real sockets and reopened disk stores prove identity and authentication boundaries. */ -class ConductarrSessionTest { - @Test fun switchingPersonRebuildsIdentityAndNeverServesTheOtherPersonsDiskCopy() = runBlocking { - val directory = Files.createTempDirectory("tv-session-").toFile() - try { - FakeConductarr().use { fake -> - ConductarrSession(FileCacheStore(directory)).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - val first = session.identity.value!! - assertEquals("shared", first.operator!!.requester) - assertEquals("Moon", first.provider.discover("shared").last().picks.single().title) - session.select("server:mindel", MINDEL_TOKEN, fake.url) - val second = session.identity.value!! - assertNotSame(first.client, second.client) - assertFalse(second.signedIn) - assertTrue(second.client.read(TvRoutes.feed("shared"), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - session.authenticate() - assertEquals("Mindel", session.identity.value!!.operator!!.name) - val mindelCall = fake.calls.last { it.url.encodedPath == "/api/v1/operator" } - assertEquals("MediaBrowser Token=\"$MINDEL_TOKEN\"", mindelCall.headers["Authorization"]) - session.select("server:other", SHARED_TOKEN, fake.url) - assertTrue(session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - session.select("server:shared", SHARED_TOKEN, fake.url) - assertEquals("shared", (session.identity.value!!.client.read(TvRoutes.operator(), ReadTrigger.CACHE_ONLY).last() as ReadResult.Value).saved.value!!.requester) - session.select("server:shared", null, fake.url) - assertNull(session.identity.value) - } - fake.calls.forEach { request -> - assertTrue(request.headers["Authorization"] in setOf("MediaBrowser Token=\"$SHARED_TOKEN\"", "MediaBrowser Token=\"$MINDEL_TOKEN\"")) - listOf("Cookie", "X-Emby-Token", "X-MediaBrowser-Token", "X-Api-Key").forEach { assertNull(request.headers[it]) } - assertFalse(request.url.toString().contains(SHARED_TOKEN)) - } - } - assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(SHARED_TOKEN) || it.readText().contains(MINDEL_TOKEN) }) - } finally { directory.deleteRecursively() } - } - - @Test fun offlineReopenPaintsSavedDataBut401RemovesRatingsAndRequestAuthority() = runBlocking { - val directory = Files.createTempDirectory("tv-offline-").toFile() - try { - FakeConductarr().use { fake -> - ConductarrSession(FileCacheStore(directory)).use { original -> - original.select("server:shared", SHARED_TOKEN, fake.url) - original.authenticate() - original.identity.value!!.provider.discover("shared").toList() - } - ConductarrSession(FileCacheStore(directory)).use { reopened -> - reopened.select("server:shared", SHARED_TOKEN, fake.url) - fake.status = 503 - reopened.authenticate() - assertTrue(reopened.identity.value!!.signedIn) - val snapshots = reopened.identity.value!!.provider.discover("shared", force = true).toList() - assertEquals("Moon", snapshots.first().picks.single().title) - assertEquals("Moon", snapshots.last().picks.single().title) - assertNotNull(snapshots.last().savedAt) - fake.status = 200 - fake.ratingsStatus = 401 - val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) - assertTrue(ratings!!.isEmpty()) - assertFalse(reopened.identity.value!!.signedIn) - assertNull(reopened.identity.value!!.operator) - assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) - assertEquals(DiscoverStatus.NeedsSignIn, reopened.identity.value!!.provider.discover("shared").last().status) - fake.ratingsStatus = 200 - reopened.select("server:shared", SHARED_TOKEN, fake.url, retry = true) - reopened.authenticate() - assertTrue(reopened.identity.value!!.signedIn) - } - } - } finally { directory.deleteRecursively() } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt deleted file mode 100644 index 265dcbd9..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The shipped host reads a local server and draws real data, dwell ratings and 401 recovery. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrSurfaceTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun realHttpProviderDrawsCatalogueAnd401ReturnsToSignInWithoutRequestActions() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { - pickerVisible = false - enterDestination("discover") - } - lateinit var current: DiscoverState - val art = ImageBitmap(1, 1) - rule.setContent { - val identity by session.identity.collectAsState() - val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false) - current = state - CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) { - WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } - } - } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - current.snapshot.status == DiscoverStatus.Ready - } - rule.onNodeWithText("Moon").assertIsDisplayed() - rule.mainClock.autoAdvance = false - rule.runOnIdle { - val lane = current.lanes.indexOfFirst { it.titles.any { title -> title.tmdb == 43L } } - current.focus(current.firstShelf + lane, 0) - } - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - rule.mainClock.advanceTimeBy(100) - org.junit.Assert.assertFalse(fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }) - rule.mainClock.advanceTimeBy(500) - rule.waitUntil(10_000) { - rule.mainClock.advanceTimeByFrame() - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } - } - rule.waitForIdle() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "conductarr-real-provider.png").absolutePath) - fake.ratingsStatus = 401 - runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.mainClock.autoAdvance = true - rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.NeedsSignIn } - rule.onNodeWithText("Discover needs sign-in").assertIsDisplayed() - org.junit.Assert.assertFalse(current.canRequest) - rule.onRoot().captureRoboImage(File(directory, "conductarr-401.png").absolutePath) - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt deleted file mode 100644 index 3c77fa66..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.Rating -import org.justinvisser.wolfskin.design.* - -class ConductarrTitlePagesTest { - @Test fun catalogueEvidenceNeverGrantsPlaybackAndOfflinePagesKeepTheirLastKnownGoodFacts() = runBlocking { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - val identity = session.identity.value!! - identity.provider.audiences() - val pages = ConductarrTitlePages(identity) - val live = pages.title(42, MediaType.MOVIE).last() - val state = TitleState(live, ShellPerson("shared", "Shared", "S", true)) {} - assertEquals(97 * 60L, live.item.runtimeSeconds) - assertEquals(listOf("Comedy"), live.item.genres) - assertEquals("216", live.cast.single().id) - assertEquals("Sam Bell", live.cast.single().character) - assertEquals("movie:43", live.related.single().id) - assertFalse(live.item.held) - assertNull(live.playTarget) - assertFalse(state.options.any { it.id in listOf("play", "watched", "version") }) - assertTrue(state.options.any { it.id == "request" }) - val actor = pages.person(216).last() - assertEquals("Actor.", actor.person!!.biography) - assertEquals("Sam Bell", actor.credits.single().character) - assertFalse(actor.credits.single().held) - assertFalse(actor.hasMore) - fake.status = 503 - val saved = pages.title(42, MediaType.MOVIE).last() - assertEquals(live.item, saved.item) - assertEquals(live.cast, saved.cast) - assertEquals(TitleConnection.Offline, saved.connection) - assertNotNull(saved.savedAt) - assertFalse(TitleState(saved, state.person) {}.options.any { it.id == "request" }) - } - } - } - - @Test fun onlyResolvedJellyfinEpisodesArePlayableAndTheirObservedVotesReplaceSeriesScores() = runBlocking { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - fake.seasonTwoScore = 7.2 - val episode = TitleItem("00000000-0000-0000-0000-000000000012", "Episode", "episode", held = true, season = 1, episode = 1) - val held = TitlePageData(TitleItem("00000000-0000-0000-0000-000000000011", "Dark", "tv", held = true, seasons = 2), - playTarget = episode, watchTarget = episode, episodes = listOf(episode.copy(id = "00000000-0000-0000-0000-000000000013", season = 2)), selectedSeason = 2) - val page = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() - assertEquals(episode.id, page.playTarget!!.id) - assertEquals(episode.id, page.watchTarget.id) - assertEquals(held.item.id, page.item.id) - assertEquals(44L, page.episodes.single().tmdbId) - assertTrue(page.episodes.single().ratings.last().episode) - assertEquals(8.1, page.playTarget!!.ratings.last().score, 0.0) - assertEquals(7.2, page.episodes.single().ratings.last().score, 0.0) - assertFalse(page.item.ratings.any { it.episode }) - fake.status = 401 - val revoked = ConductarrTitlePages(session.identity.value!!).title(44, MediaType.TV, held).last() - assertFalse(session.identity.value!!.signedIn) - assertEquals(TitleConnection.SignedOut, revoked.connection) - assertTrue(revoked.item.ratings.isEmpty()) - assertTrue(revoked.playTarget!!.ratings.isEmpty()) - assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" }) - } - } - } - - @Test fun ratingsUseObservedLabelsPrecedenceAndPositiveVoteCountsWithoutDiscardingRealZeroes() { - fun ratings(json: String) = Json.decodeFromString<List<Rating>>(json).screenRatings("tv") - assertEquals(listOf("rtCritics"), ratings("""[{"source":"rt_critics","state":"observed","score":0},{"source":"tmdb","state":"observed","score":9,"votes":20}]""").map { it.source }) - assertNull(ratings("""[{"source":"rt_critics","state":"observed","score":99,"status":"unknown"}]""").single().label) - assertTrue(ratings("""[{"source":"tmdb","state":"observed","score":7,"votes":0},{"source":"imdb","state":"observed","score":8,"votes":10}]""").isEmpty()) - assertEquals(0.0, ratings("""[{"source":"tmdb","state":"observed","score":0,"votes":1}]""").single().score, 0.0) - assertTrue(emptyList<LibraryRating>().withEpisode(0.0, 1).single().episode) - assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty()) - assertTrue(emptyList<LibraryRating>().withEpisode(8.0, 0).isEmpty()) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt deleted file mode 100644 index e41862d8..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewModelScope -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.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.mockk -import java.io.File -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.* -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ConductarrTitleSurfaceTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun realTitleOffersNoFakePlaybackAndSharedRequestForMindelHasARealReceipt() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val facade = mockk<TitleReadFacade>() - coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true)) - try { - val route = titleRoute("movie:42") - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { - pickerVisible = false; pickerLaunch = false; enterDestination(route) - } - val art = mutableMapOf<String, ImageBitmap>() - fun decode(element: JsonElement) { - when (element) { - is JsonObject -> element.values.forEach(::decode) - is JsonArray -> element.forEach(::decode) - is JsonPrimitive -> element.contentOrNull?.takeIf { it.startsWith("http") || it.startsWith('/') }?.let { art[catalogueImage(it)!!] = ImageBitmap(1, 1) } - } - } - decode(fake.wire) - rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { - TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false) - } } } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" } - } - rule.onNodeWithText("Moon").assertIsDisplayed() - assertFalse(viewModel.title!!.options.any { it.id == "play" }) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } - rule.onNodeWithText("Request for Mindel").assertIsDisplayed() - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.requestActor == "Shared" - } - assertEquals("Mindel", viewModel.title!!.data.requestFor) - rule.onAllNodesWithText("for Mindel · by Shared")[0].assertIsDisplayed() - val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" } - val body = Json.parseToJsonElement(post.body!!.utf8()).jsonObject - assertEquals("mindel", body.getValue("requester").jsonPrimitive.content) - assertFalse(body.containsKey("actor")) - rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } - fake.ratingsStatus = 401 - runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable - } - assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) - assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) - } finally { viewModel.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt deleted file mode 100644 index d2530d4f..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import kotlinx.coroutines.CompletableDeferred -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.DiscoverFixture -import org.justinvisser.wolfskin.design.DiscoverScreen -import org.justinvisser.wolfskin.design.DiscoverState -import org.justinvisser.wolfskin.design.DiscoverStatus -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.ShellSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The real asynchronous host must return focus from its loading nav to the first loaded action. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverLoadingFocusTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() { - val pending = CompletableDeferred<DiscoverFixture>() - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { - pickerVisible = false - enterDestination("discover") - } - lateinit var current: DiscoverState - rule.setContent { - val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() }) - current = state - WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } - } - rule.runOnIdle { - assertEquals(DiscoverStatus.Loading, current.snapshot.status) - assertTrue(shell.navFocused) - val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } - pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }) - } - rule.waitForIdle() - rule.runOnIdle { - assertEquals(DiscoverStatus.Ready, current.snapshot.status) - assertFalse(shell.navFocused) - assertEquals(0, current.focusedRow) - } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - rule.runOnIdle { - assertNotNull(current.title) - assertEquals("The Amazing Spider-Man", current.title!!.title) - } - } - - @Test fun finishingLoadPreservesANavTargetChosenWhileLoading() { - val pending = CompletableDeferred<DiscoverFixture>() - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { - pickerVisible = false - enterDestination("discover") - } - lateinit var current: DiscoverState - rule.setContent { - val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() }) - current = state - WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } - } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) } - rule.runOnIdle { - assertEquals(DiscoverStatus.Loading, current.snapshot.status) - assertEquals(2, shell.navIndex) - val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } - pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" }) - } - rule.waitForIdle() - rule.runOnIdle { - assertEquals(DiscoverStatus.Ready, current.snapshot.status) - assertTrue(shell.navFocused) - assertEquals(2, shell.navIndex) - } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - rule.runOnIdle { - assertEquals("movies", shell.route) - assertEquals(null, current.title) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt deleted file mode 100644 index cd7d4208..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverSnapshotReadTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** No drawn screen may prewarm memory before the production Discover observer starts. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class DiscoverSnapshotReadTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun signedInHostObservesFreshShellThroughTheRealAsyncProvider() { - FakeConductarr().use { fake -> - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val identity = requireNotNull(session.identity.value) - assertTrue(identity.signedIn) - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared") - lateinit var current: DiscoverState - rule.setContent { - current = rememberDiscoverState(shell, shell::enterDestination, {}, identity = identity, fixtures = false, - loadFixture = { error("Production observer must not load fixtures") }) - } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - current.snapshot.status == DiscoverStatus.Ready - } - rule.waitForIdle() - rule.runOnIdle { - assertTrue(fake.calls.any { it.url.encodedPath.endsWith("/feed") }) - assertEquals(0, current.focusedRow) - assertEquals(0, current.column) - current.focus(current.firstShelf) - } - rule.waitForIdle() - rule.runOnIdle { - assertNotNull(current.focusedLane) - assertEquals(current.firstShelf, current.focusedRow) - assertEquals(0, current.column) - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt deleted file mode 100644 index 9fbbeaa0..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.* -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import io.mockk.coEvery -import io.mockk.mockk -import java.util.concurrent.CountDownLatch -import kotlinx.coroutines.cancel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverTitleTimingTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() { - FakeConductarr().use { fake -> - fake.localArt = true - ConductarrSession(MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - runBlocking { session.authenticate() } - val identity = session.identity.value!! - val facade = mockk<TitleReadFacade>() - coEvery { facade.findHeld(any(), any()) } returns null - val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true)) - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { - pickerVisible = false; pickerLaunch = false; enterDestination("discover") - } - lateinit var discover: DiscoverState - val release = CountDownLatch(1) - try { - rule.setContent { - val route = (navigation.backStack.last() as Destination.Wolfskin).route - LaunchedEffect(route) { shell.enterDestination(route) } - discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false) - WolfskinTheme { - if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) - else ShellSurface(shell) { DiscoverScreen(discover, it) } - } - } - rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready } - rule.runOnIdle { - fake.beforeResponse = { if (it.endsWith("/cast")) release.await() } - discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning")) - } - rule.waitForIdle() - assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/")) - rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } } - rule.runOnIdle { release.countDown() } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true - } - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - assertEquals("cast", model.title!!.row) - rule.onNodeWithText("Sam Rockwell").assertIsDisplayed() - val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } - assertEquals("related", model.title!!.row) - rule.onNodeWithText("More like this").assertIsDisplayed() - rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath) - rule.onRoot().performKeyInput { pressKey(Key.Back) } - rule.waitForIdle() - assertNull(discover.title) - assertEquals("discover", shell.route) - } finally { - release.countDown() - model.viewModelScope.cancel() - } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt deleted file mode 100644 index f741223e..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.io.Closeable -import java.util.concurrent.CopyOnWriteArrayList -import kotlinx.serialization.json.* -import mockwebserver3.Dispatcher -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import mockwebserver3.RecordedRequest - -internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef" -internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210" -internal const val JUSTIN_TOKEN = "11111111111111111111111111111111" - -/** A loopback server supplies wire JSON, never app projections or live service state. */ -internal class FakeConductarr : Closeable { - val server = MockWebServer() - val calls = CopyOnWriteArrayList<RecordedRequest>() - val wire = Json.parseToJsonElement(javaClass.getResourceAsStream("/conductarr/wire.json")!!.bufferedReader().use { it.readText() }).jsonObject - @Volatile var status = 200 - @Volatile var postStatus = 200 - @Volatile var existingRequests = false - @Volatile var notModified = false - @Volatile var ratingsStatus = 200 - @Volatile var audiencesStatus = 200 - @Volatile var emptyAudiences = false - @Volatile var seasonTwoScore = 8.1 - @Volatile var localArt = false - @Volatile var beforeResponse: ((String) -> Unit)? = null - @Volatile var audiencesBody: String? = null - private val imageBytes = java.util.Base64.getDecoder().decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") - - init { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - calls.add(request) - if (status != 200) return response("", status) - if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder() - .body(okio.Buffer().write(imageBytes)).build() - val path = request.url.encodedPath - beforeResponse?.invoke(path) - if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) - if (request.method == "POST") return response(wire.getValue("hide").toString()) - if (request.method == "DELETE") return response(wire.getValue("restore").toString()) - val key = when { - path == "/api/v1/operator" -> "operator" - path == "/api/v1/recommendations/profiles" -> "profiles" - path == "/curation/library-audiences" -> "audiences" - path.endsWith("/feed") -> "feed" - path.endsWith("/dismissals") -> "dismissals" - path == "/catalogue/options" -> "options" - path == "/catalogue/discover" -> "discover" - path.endsWith("/ratings") -> "ratings" - path.endsWith("/cast") -> "cast" - path.endsWith("/related") -> "discover" - path.endsWith("/enrichment") -> "enrichment" - path.endsWith("/display") -> "display" - path.contains("/seasons/") -> "season" - path.endsWith("/filmography") -> "filmography" - path.startsWith("/catalogue/performers/") -> "performer" - path.startsWith("/catalogue/titles/") -> "detail" - path.startsWith("/api/v1/activity/titles/") -> "readiness" - path.startsWith("/api/v1/requests/titles/") -> "requests" - path == "/api/v1/activity" -> "activity" - else -> return response("", 404) - } - if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) - if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) - if (key == "audiences") audiencesBody?.let { return response(it) } - if (notModified && request.headers["If-None-Match"] != null) return response("", 304) - if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") - var body = wire.getValue(key).jsonObject - if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList()))) - if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { - body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) - } - if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) { - body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin"))) - } - if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") { - body = JsonObject(body + ("media_type" to JsonPrimitive("tv"))) - if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark"))))))) - } - if (key == "discover" && request.url.queryParameter("page") == "2") body = JsonObject(body + ("page" to JsonPrimitive(2))) - if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map { - JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore))) - }))) - val responseBody = if (localArt) localizeImages(body).toString() else body.toString() - return response(responseBody) - } - } - server.start() - } - - val url: String get() = server.url("/").toString() - private fun localizeImages(element: JsonElement): JsonElement = when (element) { - is JsonObject -> JsonObject(element.mapValues { (key, value) -> - if (key in setOf("poster_url", "poster_path", "backdrop_url", "backdrop_path", "profile_url") && value is JsonPrimitive && value.contentOrNull != null) - JsonPrimitive(server.url("/image").toString()) else localizeImages(value) - }) - is JsonArray -> JsonArray(element.map(::localizeImages)) - else -> element - } - private fun response(body: String, code: Int = 200) = MockResponse.Builder().code(code).body(body).addHeader("ETag", "\"wire-v1\"").build() - override fun close() = server.close() -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt deleted file mode 100644 index aa1faf95..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.successQueryResult -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.cancel -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.UserViewsApi -import org.jellyfin.sdk.model.api.* -import org.junit.Assert.* -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config - -/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class FirstSignInNavigationTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var previousUrl: String - private var previousFixtures = false - private var previousScreens = true - - @Before fun captureGlobalPreferences() { - ConductarrPreference.initialize(rule.activity) - WolfskinScreenPreference.initialize(rule.activity) - previousUrl = ConductarrPreference.baseUrl - previousFixtures = ConductarrPreference.fixtures - previousScreens = WolfskinScreenPreference.enabled - rule.mainClock.autoAdvance = true - } - - @After fun restoreGlobalPreferences() { - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - WolfskinScreenPreference.enabled = previousScreens - rule.mainClock.autoAdvance = true - } - - @Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() { - ConductarrPreference.updateFixtures(false) - WolfskinScreenPreference.enabled = true - FakeConductarr().use { fake -> - fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}""" - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) - val api = mockk<ApiClient>() - val views = mockk<UserViewsApi>() - val items = mockk<ItemsApi>() - every { api.userViewsApi } returns views - every { api.itemsApi } returns items - val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) - val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS) - coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows)) - for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) { - coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true, - includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), - sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult( - List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) }) - } - val navigation = NavigationManager() - val session = WolfskinSession() - val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( - HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) - try { - rule.setContent { - WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model, - homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) - } - rule.waitUntil(10_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - conductarr.identity.value?.signedIn == true && model.libraries.audiences != null - } - rule.onNodeWithText("Mindel").assertIsDisplayed() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() - assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { - pressKey(Key.DirectionUp) - pressKey(Key.DirectionRight) - pressKey(Key.DirectionRight) - pressKey(Key.DirectionCenter) - } - rule.waitForIdle() - rule.waitUntil(10_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty() - } - rule.onNodeWithTag("library-grid").assertIsDisplayed() - rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed() - rule.onNodeWithText("Desperate Housewives").assertDoesNotExist() - assertEquals(6, session.state!!.view.library!!.source.size) - assertFalse(session.state!!.person.hasAnime) - rule.onNodeWithText("Shows").performClick() - rule.waitForIdle() - try { - rule.waitUntil(timeoutMillis = 15_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - rule.onAllNodes(hasTestTag("library-grid") and - hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty() - } - } catch (timeout: ComposeTimeoutException) { - val shell = session.state!! - throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " + - "movies=${shell.rememberedView("movies").library?.source?.size}, " + - "shows=${shell.rememberedView("shows").library?.source?.size}/" + - "${shell.rememberedView("shows").library?.status}", timeout) - } - rule.onNodeWithTag("library-grid").assertIsDisplayed() - assertEquals(29, session.state!!.view.library!!.source.size) - rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed() - rule.onNodeWithText("Home").performClick() - rule.waitForIdle() - rule.onNodeWithText("Desperate Housewives").assertIsDisplayed() - assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible) - } finally { model.viewModelScope.cancel() } - } - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt deleted file mode 100644 index 49fc269f..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.justinvisser.wolfskin.design.HomeLibrary -import org.justinvisser.wolfskin.design.HomeTitle -import org.justinvisser.wolfskin.tv.read.HomeLibraryCache - -/** A reopened Home cache preserves playable identity and episode/resume state without network reads. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class HomeLibraryCacheTest { - @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { - val context = ApplicationProvider.getApplicationContext<Application>() - val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) - val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) - val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) - HomeLibraryCache(context).write("server:person", library, 12345L) - val saved = HomeLibraryCache(context).read("server:person")!! - assertEquals(library, saved.first) - assertEquals(12345L, saved.second) - assertNull(HomeLibraryCache(context).read("server:someone-else")) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt deleted file mode 100644 index dc0b780d..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.IOException -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Saved Home projections survive outages but never survive an authenticated identity change. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class HomeRecoveryTest { - private val context = ApplicationProvider.getApplicationContext<Application>() - private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel") - private val viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString()) - private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString()) - private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400) - private val library = HomeLibrary(resume = listOf(film)) - - @Test fun savedHomePaintsBeforeRefreshAndStaysAfterFailedRetry() = runBlocking { - val cache = HomeLibraryCache(context) - cache.write("${server.id}:${personA.id}", library, 1000L) - val started = CompletableDeferred<Unit>() - val response = CompletableDeferred<HomeLibrary>() - val facade = mockk<HomeReadFacade>() - coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() } - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } - started.await() - assertEquals(film.id, model.snapshot.rows.first().cards.first().id) - response.completeExceptionally(IOException("fixture offline")) - refresh.await() - assertFalse(model.snapshot.jellyfinAvailable) - assertEquals(film.id, model.snapshot.rows.first().cards.first().id) - assertNotNull(model.snapshot.savedAt) - assertEquals(RecoveryMessage.StillUnreachable, model.toast) - } - - @Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking { - val facade = mockk<HomeReadFacade>() - val cache = HomeLibraryCache(context) - cache.write("${server.id}:${personA.id}", library, 1000L) - val startedA = CompletableDeferred<Unit>() - val responseA = CompletableDeferred<HomeLibrary>() - coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } - coEvery { facade.read(personB.id) } throws IOException("fixture offline") - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } - startedA.await() - assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - model.loadLibrary(server, personB, viewerB) - assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - assertNull(model.snapshot.savedAt) - assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable }) - responseA.complete(library) - requestA.await() - assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - assertFalse(model.snapshot.jellyfinAvailable) - } - - @Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking { - val facade = mockk<HomeReadFacade>() - coEvery { facade.read(personA.id) } returns library - val cache = mockk<HomeLibraryCache>() - every { cache.read(any()) } returns null - every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - model.loadLibrary(server, personA, viewerA) - assertTrue(model.snapshot.jellyfinAvailable) - assertEquals(film.id, model.snapshot.rows.first().cards.first().id) - assertNull(model.toast) - } - - @Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking { - val facade = mockk<HomeReadFacade>() - coEvery { facade.read(personA.id) } returns library - val cache = mockk<HomeLibraryCache>(relaxed = true) - every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) - assertTrue(model.snapshot.jellyfinAvailable) - assertEquals(film.id, model.snapshot.rows.first().cards.first().id) - model.loadLibrary(server, personA, viewerB.copy(key = "justin")) - assertFalse(model.snapshot.jellyfinAvailable) - assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt deleted file mode 100644 index f8322b5f..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.mockk -import java.util.UUID -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.assertSame -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Home opens the complete shared grid and Back restores its original continuation card. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class HomeSeeAllNavigationTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun personalMovieContinuationReachesTheEighteenthTitleAndReturnsToHome() = continuation("pk-m", 5) { grid -> - assertEquals(18, grid.items.size) - repeat(2) { press(Key.DirectionDown) } - repeat(5) { press(Key.DirectionRight) } - rule.onNodeWithText("Bleach the Movie: Fade to Black").assertIsDisplayed() - assertEquals("movie-bleach-the-movie-fade-to-black-2008", grid.focusedTitle?.id) - } - - @Test fun horizontallyScrolledContinuationRestoresTheExactHomeViewport() = continuation("trending", 20) { grid -> - press(Key.DirectionDown) - assertEquals(6, grid.index) - rule.onNodeWithTag("library-grid").assertIsDisplayed() - } - - private fun continuation(rowId: String, expectedColumn: Int, exerciseGrid: (LibraryGridState) -> Unit) { - rule.mainClock.autoAdvance = false - val snapshot = HomeFixture(rule.activity).snapshot("justin") - val row = snapshot.rows.first { it.id == rowId } - val cardTag = "home-card:$rowId:$expectedColumn" - val navigation = NavigationManager() - val lists = WolfskinSeeAllLists() - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") { - if (it == ShellAction.Pop) navigation.goBack() - }.apply { - pickerVisible = false - view.row = snapshot.rows.indexOf(row) - view.home.focusedKey = row.id - view.cards[row.id] = row.cards.size - } - val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - val facade = mockk<LibraryReadFacade>() - rule.setContent { - val destination = navigation.backStack.last() - val route = (destination as? Destination.Wolfskin)?.route ?: "home" - LaunchedEffect(destination) { shell.enterDestination(route) } - WolfskinTheme { ShellSurface(shell) { modifier -> - if (destination is Destination.Home) HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } - else WolfskinLibraryContent(rule.activity, route, shell, user, "fixture", facade, navigation, lists, modifier) - } } - } - settle() - rule.onNodeWithTag(cardTag).assertIsDisplayed() - val home = shell.view - val track = home.home.tracks.getValue(rowId) - val verticalIndex = home.listState.firstVisibleItemIndex - val verticalOffset = home.listState.firstVisibleItemScrollOffset - val rowOffset = home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset - val horizontalIndex = track.firstVisibleItemIndex - val horizontalOffset = track.firstVisibleItemScrollOffset - press(Key.DirectionCenter) - assertEquals("seeall/justin/$rowId", (navigation.backStack.last() as Destination.Wolfskin).route) - val grid = requireNotNull(lists.get("justin/$rowId")) - try { - rule.onNodeWithTag("library-grid").assertIsDisplayed() - exerciseGrid(grid) - press(Key.Back) - rule.waitForIdle() - assertTrue(navigation.backStack.last() is Destination.Home) - assertEquals(row.id, snapshot.rows[shell.view.row].id) - assertEquals(expectedColumn, shell.view.cards[row.id]) - assertSame("Home retains its original route memory", home, shell.view) - assertSame("Home retains its original horizontal state", track, home.home.tracks.getValue(rowId)) - assertEquals("Vertical first item", verticalIndex, home.listState.firstVisibleItemIndex) - assertEquals("Vertical pixel offset", verticalOffset, home.listState.firstVisibleItemScrollOffset) - assertEquals("Selected row pixel offset", rowOffset, home.listState.layoutInfo.visibleItemsInfo.first { it.key == rowId }.offset) - assertEquals("Horizontal first item", horizontalIndex, track.firstVisibleItemIndex) - assertEquals("Horizontal pixel offset", horizontalOffset, track.firstVisibleItemScrollOffset) - rule.onNodeWithTag(cardTag).assertIsDisplayed() - } catch (failure: AssertionError) { - throw AssertionError( - "Expected $rowId:$expectedColumn vertical=$verticalIndex/$verticalOffset row-offset=$rowOffset horizontal=$horizontalIndex/$horizontalOffset; " + - "actual route=${shell.route} row=${home.row} card=${home.cards[rowId]} nav=${shell.navFocused} handler=${shell.contentFocus?.javaClass?.simpleName} grid=${grid.index} " + - "vertical=${home.listState.firstVisibleItemIndex}/${home.listState.firstVisibleItemScrollOffset} horizontal=${track.firstVisibleItemIndex}/${track.firstVisibleItemScrollOffset} " + - "visible-rows=${home.listState.layoutInfo.visibleItemsInfo.map { it.key to it.offset }}", - failure, - ) - } - } - - private fun press(key: Key) { - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } - settle() - } - - private fun settle() { - repeat(3) { - rule.mainClock.advanceTimeByFrame() - rule.waitForIdle() - } - rule.mainClock.advanceTimeBy(1_000) - rule.waitForIdle() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt deleted file mode 100644 index 976f9588..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryProgressiveSurfaceTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.input.key.Key -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.every -import io.mockk.mockk -import java.io.File -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.flow -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The real host exposes usable remote focus before the remaining library chunks arrive. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class LibraryProgressiveSurfaceTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() { - ConductarrPreference.initialize(rule.activity) - ConductarrPreference.updateFixtures(false) - val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply { - pickerVisible = false - enterDestination("movies") - focusNav(currentTab) - } - val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null) - val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") } - val release = CompletableDeferred<Unit>() - val facade = mockk<LibraryReadFacade>() - every { facade.audiences } returns null - every { facade.readLibrary(user.id, "movies") } returns flow { - emit(first) - release.await() - emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie")) - } - val scope = "progressive:${UUID.randomUUID()}" - val navigation = NavigationManager() - val seeAll = WolfskinSeeAllLists() - try { - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> - WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier) - } } } - rule.waitUntil(timeoutMillis = 10_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - shell.view.library?.source?.size == 200 - } - rule.mainClock.advanceTimeBy(500) - assertFalse(shell.navFocused) - rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) } - assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath) - release.complete(Unit) - rule.waitUntil(timeoutMillis = 10_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - shell.view.library?.source?.size == 201 - } - rule.runOnIdle { - assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id) - assertFalse(shell.navFocused) - } - } finally { release.complete(Unit) } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt deleted file mode 100644 index 92afbe07..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibraryReadFacadeTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import com.github.damontecres.wholphin.ui.successQueryResult -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.UserViewsApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.justinvisser.wolfskin.tv.read.LibrarySnapshot -import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class LibraryReadFacadeTest { - @Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking { - val api = mockk<ApiClient>() - val views = mockk<UserViewsApi>() - val items = mockk<ItemsApi>() - every { api.userViewsApi } returns views - every { api.itemsApi } returns items - val user = UUID(0, 1) - val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES) - coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library)) - val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) } - val nextRead = CompletableDeferred<Unit>() - val release = CompletableDeferred<Unit>() - val offsets = mutableListOf<Int>() - coEvery { items.getItems(userId = user, parentId = library.id, recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), - sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers { - if (offsets.last() == 0) successQueryResult(firstChunk, 202) else { - assertEquals(200, offsets.last()) - nextRead.complete(Unit) - release.await() - successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202) - } - } - val facade = LibraryReadFacade(api) - val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } - val published = CompletableDeferred<LibrarySnapshot>() - val snapshots = mutableListOf<LibrarySnapshot>() - val job = launch { - store.read("server:justin", "movies") { - facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) } - }.collect { - snapshots += it - if (!published.isCompleted) published.complete(it) - } - } - val initial = published.await() - assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id }) - nextRead.await() - assertTrue(job.isActive) - release.complete(Unit) - job.join() - val final = snapshots.last() - assertEquals(201, final.items.size) - assertEquals("Updated film", final.items.first().title) - assertEquals("Last film", final.items.last().title) - assertFalse(final.unreachable) - val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single() - assertEquals(final.items, saved.items) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt deleted file mode 100644 index 279c3966..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/LibrarySnapshotStoreTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.LibraryTitle -import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore - -/** A stale library remains readable offline without crossing server/person boundaries. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class LibrarySnapshotStoreTest { - @Test fun savedCopyArrivesBeforeFailedRefreshAndFreshCopySkipsNetwork() = runBlocking { - var now = 1000L - val store = LibrarySnapshotStore(MemoryCacheStore()) { now } - val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true)) - store.read("server:justin", "movies") { flowOf(items) }.toList() - val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList() - assertEquals(items, fresh.single().items) - now += 11 * 60_000 - val offline = store.read("server:justin", "movies") { throw java.io.IOException("offline") }.toList() - assertEquals(2, offline.size) - assertFalse(offline.first().unreachable) - assertTrue(offline.last().unreachable) - assertEquals(1000L, offline.last().savedAtMillis) - assertEquals(items, offline.last().items) - for (scope in listOf("server:mindel", "other-server:justin")) { - val isolated = store.read(scope, "movies") { throw java.io.IOException("offline") }.toList().single() - assertNull(isolated.savedAtMillis) - assertTrue(isolated.items.isEmpty()) - } - } - - @Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking { - val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } - store.read("server:justin", "movies") { flowOf(emptyList()) }.toList() - val current = listOf(LibraryTitle("b", "New Film", "movie")) - val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList() - assertTrue(reads.first().items.isEmpty()) - assertEquals(current, reads.last().items) - assertFalse(reads.last().unreachable) - } - - @Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking { - val partial = listOf(LibraryTitle("partial", "First film", "movie")) - val complete = partial + LibraryTitle("last", "Last film", "movie") - for (hasSaved in listOf(false, true)) { - val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L } - if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList() - val reads = store.read("server:justin", "movies", force = true) { - kotlinx.coroutines.flow.flow { - emit(partial) - throw java.io.IOException("Later chunk failed") - } - }.toList() - assertEquals(if (hasSaved) complete else partial, reads.last().items) - assertTrue(reads.last().unreachable) - assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis) - val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList() - assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt deleted file mode 100644 index 5f2aa9e7..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toPixelMap -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Scrolled cards must be occluded across the entire persistent navigation band. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class NavBandOcclusionTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Test fun scrolledRowsNeverShowThroughTheNavBand() { - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { - pickerVisible = false; pickerLaunch = false; enterDestination("discover") - } - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> - LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) { - items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) } - } - } } } - rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } } - rule.waitForIdle() - val pixels = rule.onRoot().captureToImage().toPixelMap() - val background = WolfskinTokens.Color.bg - for (y in listOf(5, 50, 135)) { - val actual = pixels[20, y] - assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f) - assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f) - assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt deleted file mode 100644 index 8e13dca4..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.runtime.* -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import io.mockk.* -import java.util.UUID -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config - -/** A selected identity gets a new app ViewModel without opening a second launch picker. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class PersonPickerSessionTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var previousUrl: String - private var previousFixtures = false - private var previousScreens = true - private val models = mutableListOf<WolfskinShellViewModel>() - private lateinit var session: WolfskinSession - - @Before fun capturePreferences() { - ConductarrPreference.initialize(rule.activity) - WolfskinScreenPreference.initialize(rule.activity) - previousUrl = ConductarrPreference.baseUrl - previousFixtures = ConductarrPreference.fixtures - previousScreens = WolfskinScreenPreference.enabled - ConductarrPreference.updateFixtures(false) - WolfskinScreenPreference.enabled = true - } - - @After fun restorePreferences() { - models.forEach { it.viewModelScope.cancel() } - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - WolfskinScreenPreference.enabled = previousScreens - } - - private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) { - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) -> - JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token) - } - var current by mutableStateOf(CurrentUser(server, users[initial])) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } - val setup = mockk<SetupNavigationManager>(relaxed = true) - every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current } - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - coEvery { libraries.hasAnimeTitles(any()) } returns false - session = WolfskinSession() - try { - rule.setContent { - key(current.user.id) { - val navigation = remember { NavigationManager() } - val model = remember { - WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } - } - DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } - WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - viewModel = model, homeContent = { _, _, user, modifier -> - androidx.tv.material3.Text("Home for ${user.name}", modifier) - }) - } - } - awaitLoaded(users[initial]) - if (fromAvatar) { - press(listOf(Key.DirectionCenter)) - press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter)) - } - val actions = mutableListOf<ShellAction.Person>() - rule.runOnIdle { - val dispatch = session.onAction - session.onAction = { action -> - if (action is ShellAction.Person) actions.add(action) - dispatch(action) - } - } - if (fast) press(moves + Key.DirectionCenter) else { - press(moves) - press(listOf(Key.DirectionCenter)) - } - awaitLoaded(users[selected]) - rule.runOnIdle { - assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible) - assertEquals("home", session.state!!.route) - assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId) - assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId }) - assertEquals(if (initial == selected) 1 else 2, models.size) - } - rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed() - } finally { models.forEach { it.viewModelScope.cancel() } } - } - } - } - - private fun awaitLoaded(user: JellyfinUser) { - try { - rule.waitUntil(10_000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - models.lastOrNull()?.signedInPerson == user.name!!.lowercase() && - models.last().conductarr.identity.value?.signedIn == true && - models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true && - session.state?.people?.size == 3 && - models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive } - } - } catch (timeout: ComposeTimeoutException) { - throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " + - "identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " + - "person=${session.state?.person}, people=${session.state?.people?.size}", timeout) - } - rule.waitForIdle() - } - - private fun press(keys: List<Key>) { - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } } - } - - @Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight)) - @Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft)) - @Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight)) - @Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft)) - @Test fun initialPersonNeedsOnlyOneOk() = select(0, 0) - @Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp)) - @Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft)) -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt deleted file mode 100644 index 20ea6d9b..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.lifecycle.viewModelScope -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.* -import com.github.damontecres.wholphin.services.SetupDestination -import com.github.damontecres.wholphin.services.SetupNavigationManager -import io.mockk.* -import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class PersonSwitchThreadingTest { - private val context = ApplicationProvider.getApplicationContext<Application>() - private lateinit var previousUrl: String - private var previousFixtures = false - - @Before fun setUp() { - Dispatchers.setMain(Dispatchers.Unconfined) - ConductarrPreference.initialize(context) - previousUrl = ConductarrPreference.baseUrl - previousFixtures = ConductarrPreference.fixtures - ConductarrPreference.updateFixtures(false) - } - - @After fun tearDown() { - ConductarrPreference.updateBaseUrl(previousUrl) - ConductarrPreference.updateFixtures(previousFixtures) - Dispatchers.resetMain() - unmockkConstructor(ConductarrClient::class) - } - - @Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking { - assertSame(Looper.getMainLooper(), Looper.myLooper()) - val closeThreads = CopyOnWriteArrayList<Boolean>() - val releaseCloses = CountDownLatch(1) - val closed = CountDownLatch(4) - val disposalErrors = CopyOnWriteArrayList<Throwable>() - val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) }) - val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true) - mockkConstructor(ConductarrClient::class) - every { anyConstructed<ConductarrClient>().close() } answers { - val onMain = Looper.myLooper() == Looper.getMainLooper() - closeThreads.add(onMain) - check(!onMain) { "StrictMode: Conductarr socket close on main" } - check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" } - callOriginal() - closed.countDown() - if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure") - } - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) - val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), - justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } - val setup = mockk<SetupNavigationManager>(relaxed = true) - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - val session = WolfskinSession() - val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - val clients = mutableListOf<ConductarrClient>() - try { - model.load(server, justin) - requireNotNull(model.onForeground(server, justin)).join() - clients.add(conductarr.identity.value!!.client) - val shell = session.state!! - shell.key(ShellKey.Ok) - for (index in listOf(1, 2, 0)) { - val switched = CompletableDeferred<CurrentUser>() - every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } - shell.focusNav(shell.tabs.size) - shell.key(ShellKey.Ok) - shell.focusPerson(index) - shell.key(ShellKey.Ok) - assertEquals(users[index].id.toString(), shell.person.jellyfinUserId) - assertEquals("home", shell.route) - assertFalse(shell.pickerVisible) - assertNull("Old identity must not serve the newly selected person", conductarr.identity.value) - val current = withTimeout(5_000) { switched.await() } - assertEquals(users[index].id, current.user.id) - model.load(current.server, current.user) - requireNotNull(model.onForeground(server, current.user)).join() - assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester) - assertEquals(users[index].name!!.lowercase(), model.signedInPerson) - clients.add(conductarr.identity.value!!.client) - } - conductarr.close() - conductarr.close() - model.viewModelScope.cancel() - releaseCloses.countDown() - assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) }) - assertEquals(listOf(false, false, false, false), closeThreads.toList()) - for (client in clients) { - val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last() - assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error) - } - disposalScope.coroutineContext.job.children.toList().joinAll() - assertTrue("A failing socket close must be contained", disposalErrors.isEmpty()) - } finally { - releaseCloses.countDown() - model.viewModelScope.cancel() - withContext(Dispatchers.IO) { conductarr.close() } - disposalScope.coroutineContext.job.children.toList().joinAll() - disposalScope.cancel() - } - } - } - - @Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking { - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) - val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN), - justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN)) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users) - val pending = CompletableDeferred<Unit>() - val release = CompletableDeferred<Unit>() - coEvery { repository.changeUser(server, any()) } coAnswers { - val user = secondArg<JellyfinUser>() - if (user.id == users[1].id) { - pending.complete(Unit) - withContext(NonCancellable) { release.await() } - } - CurrentUser(server, user) - } - val switched = CompletableDeferred<CurrentUser>() - val setup = mockk<SetupNavigationManager>(relaxed = true) - every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) } - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - val session = WolfskinSession() - val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - try { - model.load(server, justin) - requireNotNull(model.onForeground(server, justin)).join() - val shell = session.state!! - shell.key(ShellKey.Ok) - for (index in listOf(1, 2, 0)) { - shell.focusNav(shell.tabs.size) - shell.key(ShellKey.Ok) - shell.focusPerson(index) - shell.key(ShellKey.Ok) - if (index == 1) withTimeout(5_000) { pending.await() } - } - release.complete(Unit) - val current = withTimeout(5_000) { switched.await() } - assertEquals(justin.id, current.user.id) - verify(exactly = 1) { setup.navigateTo(any()) } - model.load(server, current.user) - requireNotNull(model.onForeground(server, current.user)).join() - assertEquals(justin.id.toString(), shell.person.jellyfinUserId) - assertEquals("justin", model.signedInPerson) - assertEquals("justin", conductarr.identity.value!!.operator!!.requester) - } finally { - release.complete(Unit) - model.viewModelScope.cancel() - } - } - } - } - - @Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking { - val closeThreads = CopyOnWriteArrayList<Boolean>() - val closed = CountDownLatch(3) - val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - mockkConstructor(ConductarrClient::class) - every { anyConstructed<ConductarrClient>().close() } answers { - val onMain = Looper.myLooper() == Looper.getMainLooper() - closeThreads.add(onMain) - check(!onMain) { "StrictMode: retry socket close on main" } - callOriginal() - closed.countDown() - } - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope) - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN) - val repository = mockk<ServerRepository>() - every { repository.currentUser } returns user - every { repository.currentServer } returns server - val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>() - coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf( - org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true))) - val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true) - every { cache.read(any()) } returns null - val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) - val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository) - try { - conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) - conductarr.authenticate() - fake.status = 401 - conductarr.authenticate() - assertFalse(conductarr.identity.value!!.signedIn) - fake.status = 200 - homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true) - withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } } - assertEquals("justin", conductarr.identity.value!!.operator!!.requester) - assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } }) - fake.status = 401 - conductarr.authenticate() - assertFalse(conductarr.identity.value!!.signedIn) - fake.status = 200 - titleModel.retrySignIn() - assertEquals("justin", conductarr.identity.value!!.operator!!.requester) - assertTrue(conductarr.identity.value!!.signedIn) - } finally { - homeModel.viewModelScope.cancel() - titleModel.viewModelScope.cancel() - conductarr.close() - disposalScope.coroutineContext.job.children.toList().joinAll() - disposalScope.cancel() - } - assertEquals(0L, closed.count) - assertEquals(listOf(false, false, false), closeThreads.toList()) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt deleted file mode 100644 index 92c0a248..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import java.io.File -import java.io.IOException -import java.util.UUID -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.Shadows.shadowOf -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.HomeReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Actual Home actions either open upstream playback or leave focus and the saved Home intact. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class PlayerHandoffTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - private val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Mindel", serverId = server.id, accessToken = null) - private val navigation = NavigationManager() - private val facade = mockk<HomeReadFacade>() - private lateinit var model: WolfskinHomeViewModel - private lateinit var shell: ShellState - - @Before fun setup() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } - model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) - } - - @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { - val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") - val title = snapshot.rows.first().cards.first() - coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline") - rule.setContent { WolfskinTheme { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> - HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) } - model.toast?.let { RecoveryToast(it) } - } - } } - rule.mainClock.advanceTimeBy(1000) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - awaitModel { model.toast != null } - rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() - assertTrue(navigation.backStack.single() is Destination.Home) - assertFalse(shell.navFocused) - assertEquals("hero", snapshot.rows[shell.view.row].id) - assertEquals(0, shell.view.cards["hero"] ?: 0) - val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath) - } - - @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { - val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() - val id = UUID.fromString("00000000-0000-0000-0000-000000000003") - coEvery { facade.playableId(user.id, title) } returns id - val view = shell.view - view.cards["hero"] = 0 - rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) } - awaitModel { navigation.backStack.last() is Destination.Playback } - val destination = navigation.backStack.last() as Destination.Playback - assertEquals(id, destination.itemId) - assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs) - assertTrue(destination.fullScreen) - navigation.goBack() - assertTrue(navigation.backStack.single() is Destination.Home) - assertSame(view, shell.view) - assertEquals(0, shell.view.cards["hero"]) - assertNull(model.toast) - } - - @Test fun anotherPersonsSessionCannotResolveOrStartPlayback() { - val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() - val otherUser = user.copy(name = "Justin") - rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) } - awaitModel { model.toast != null } - assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast) - assertTrue(navigation.backStack.single() is Destination.Home) - coVerify(exactly = 0) { facade.playableId(any(), any()) } - } - private fun awaitModel(condition: () -> Boolean) { - rule.waitUntil(5000) { - shadowOf(Looper.getMainLooper()).idle() - condition() - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt deleted file mode 100644 index a90bb4b1..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/RequestsRefreshTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test -import org.justinvisser.wolfskin.design.ComingInRequest -import org.justinvisser.wolfskin.design.ComingInSnapshot -import org.justinvisser.wolfskin.design.ComingInStage -import org.justinvisser.wolfskin.design.ComingInStatus -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState - -/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */ -@OptIn(ExperimentalCoroutinesApi::class) -class RequestsRefreshTest { - @Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest { - val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin") - val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting) - state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) - var reads = 0 - val active = launch { refreshRequestsWhileStarted(state) { reads++ } } - runCurrent() - assertEquals(1, reads) - advanceTimeBy(30_000); runCurrent() - assertEquals("Closed and settled does not poll", 1, reads) - state.openRequests() - advanceTimeBy(29_999); runCurrent() - assertEquals(1, reads) - advanceTimeBy(1); runCurrent() - assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads) - state.closeRequests() - state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true)) - advanceTimeBy(30_000); runCurrent() - assertEquals("An unsettled request refreshes after the sheet closes", 3, reads) - state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true)) - advanceTimeBy(30_000); runCurrent() - assertEquals("Ready requests stop polling", 3, reads) - active.cancel(); runCurrent() - state.openRequests() - advanceTimeBy(60_000); runCurrent() - assertEquals("A stopped lifecycle cannot poll", 3, reads) - val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } } - runCurrent() - assertEquals("Returning to the foreground refreshes immediately", 4, reads) - resumed.cancel() - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt deleted file mode 100644 index dd5ee44c..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.services.SeerrRatingsService -import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState -import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage -import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel -import com.github.damontecres.wholphin.util.LoadingState -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.every -import io.mockk.mockk -import java.io.File -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The frozen st-094 state opens User profile settings, not the separate Playback press path. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class SettingsHandOffCaptureTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - @Test fun st094_realUserProfileHomeSettingsHandOff() { - val model = mockk<HomeSettingsViewModel>(relaxed = true) - every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success)) - every { model.serverPluginActive } returns MutableStateFlow(false) - every { model.settingsChanged } returns MutableStateFlow(false) - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true)) - val images = mockk<ImageUrlService>(relaxed = true) - rule.setContent { - WolfskinTheme { - CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) { - HomeSettingsPage(preferences, Modifier.fillMaxSize().background(WolfskinTokens.Color.bg), viewModel = model) - } - } - } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - rule.onNodeWithText(rule.activity.getString(R.string.customize_home)).assertIsDisplayed() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "st-094.png").absolutePath) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt deleted file mode 100644 index a5b181f7..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import java.util.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -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 - -/** The actual Advanced preference persists its value and changes the shell's renderer policy. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ShellRoutingTest { - private val context: Application get() = ApplicationProvider.getApplicationContext() - private val movies = Destination.MediaItem(UUID.fromString("00000000-0000-0000-0000-000000000001"), BaseItemKind.COLLECTION_FOLDER, CollectionType.MOVIES) - private val libraries = mapOf("movies" to movies, "shows" to movies, "anime" to movies) - - @Before fun initialize() { WolfskinScreenPreference.initialize(context); WolfskinScreenPreference.enabled = true } - @After fun restore() { WolfskinScreenPreference.enabled = true } - - @Test fun advancedSwitchRestoresUpstreamHomeAndLibrariesAndPersistsAcrossReload() { - val preference = AppPreference.WolfskinScreens - val preferences = AppPreferences.getDefaultInstance() - assertNull(resolveUpstreamDestination(Destination.Home(), true, libraries)) - assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), true, libraries)) - preference.setter(preferences, false) - WolfskinScreenPreference.initialize(context) - assertFalse(preference.getter(preferences)) - assertTrue(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries) is Destination.Home) - listOf("movies", "shows", "anime").forEach { route -> - assertEquals(movies, resolveUpstreamDestination(Destination.Wolfskin(route), WolfskinScreenPreference.enabled, libraries)) - } - preference.setter(preferences, true) - assertNull(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries)) - assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), WolfskinScreenPreference.enabled, libraries)) - } - - @Test fun settingsAndPickerNeverBecomeUpstreamScreensButUnrelatedRoutesPassThrough() { - val settings = Destination.Wolfskin("settings") - val upstream = Destination.Settings(PreferenceScreenOption.ADVANCED) - for (enabled in listOf(false, true)) { - assertNull(resolveUpstreamDestination(settings, enabled, libraries)) - assertEquals(upstream, resolveUpstreamDestination(upstream, enabled, libraries)) - assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries)) - } - } - - @Test fun titleRolloutOwnsOnlyFilmSeriesEpisodeAndPerson() { - val id = UUID.fromString("00000000-0000-0000-0000-000000000001") - for (kind in listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON)) { - val destination = Destination.MediaItem(id, kind) - assertNull(resolveUpstreamDestination(destination, true, libraries)) - assertEquals(destination, resolveUpstreamDestination(destination, false, libraries)) - } - for (kind in listOf(BaseItemKind.COLLECTION_FOLDER, BaseItemKind.AUDIO, BaseItemKind.MUSIC_ALBUM, BaseItemKind.BOX_SET)) { - val destination = Destination.MediaItem(id, kind) - assertEquals(destination, resolveUpstreamDestination(destination, true, libraries)) - } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt deleted file mode 100644 index dbcffdb4..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.lifecycle.viewModelScope -import androidx.test.core.app.ApplicationProvider -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.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import io.mockk.coEvery -import io.mockk.mockk -import io.mockk.verify -import java.util.UUID -import kotlinx.coroutines.* -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.conductarr.MemoryCacheStore -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ShellStartupTimingTest { - @Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) } - @After fun resetMain() { Dispatchers.resetMain() } - - @Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking { - val context = ApplicationProvider.getApplicationContext<Application>() - ConductarrPreference.initialize(context) - ConductarrPreference.updateFixtures(false) - val previous = ConductarrPreference.baseUrl - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) - conductarr.authenticate() - val activityEntered = CompletableDeferred<Unit>() - val releaseActivity = java.util.concurrent.CountDownLatch(1) - fake.beforeResponse = { - if (it == "/api/v1/activity") { - activityEntered.complete(Unit) - releaseActivity.await() - } - } - val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() } - withTimeout(5_000) { activityEntered.await() } - val savedPeople = CompletableDeferred<Unit>() - val savedPeopleStarted = CompletableDeferred<Unit>() - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null } - val navigation = mockk<NavigationManager>(relaxed = true) - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(user.id) } returns emptyMap() - val session = WolfskinSession() - val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - val load = launch { model.load(server, user) } - try { - withTimeout(5_000) { savedPeopleStarted.await() } - val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" } - assertEquals(PeopleStatus.Ready, shell.peopleStatus) - assertEquals(user.id.toString(), shell.person.jellyfinUserId) - shell.key(ShellKey.Ok) - for (route in listOf("movies", "shows", "discover")) { - shell.focusNav(shell.tabs.indexOf(route)) - shell.key(ShellKey.Ok) - verify { navigation.replace(Destination.Wolfskin(route)) } - assertEquals(route, shell.route) - } - savedPeople.complete(Unit) - load.join() - assertSame(shell, session.state) - assertEquals("discover", shell.route) - assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted) - } finally { - releaseActivity.countDown() - activity.cancelAndJoin() - savedPeople.complete(Unit) - load.cancelAndJoin() - model.viewModelScope.cancel() - } - } - } - ConductarrPreference.updateBaseUrl(previous) - } - - @Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking { - val context = ApplicationProvider.getApplicationContext<Application>() - ConductarrPreference.initialize(context) - ConductarrPreference.updateFixtures(false) - val previous = ConductarrPreference.baseUrl - FakeConductarr().use { fake -> - ConductarrPreference.updateBaseUrl(fake.url) - ConductarrSession(MemoryCacheStore()).use { conductarr -> - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) - val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) - val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN) - val repository = mockk<ServerRepository>(relaxed = true) - coEvery { repository.serverDao.getServer(server.id) } returns - com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel)) - val switched = CompletableDeferred<Unit>() - coEvery { repository.changeUser(server, mindel) } coAnswers { - switched.complete(Unit) - com.github.damontecres.wholphin.data.CurrentUser(server, mindel) - } - val navigation = mockk<NavigationManager>(relaxed = true) - val libraries = mockk<LibraryReadFacade>(relaxed = true) - coEvery { libraries.readDestinations(any()) } returns emptyMap() - val session = WolfskinSession() - val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) - val pending = CompletableDeferred<Unit>() - val release = java.util.concurrent.CountDownLatch(1) - val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) - fake.beforeResponse = { - if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) { - pending.complete(Unit) - release.await() - } - } - try { - model.load(server, shared) - withTimeout(5_000) { pending.await() } - val shell = requireNotNull(session.state) - shell.key(ShellKey.Ok) - shell.enterDestination("discover") - shell.focusNav(shell.tabs.size) - shell.key(ShellKey.Ok) - shell.focusPerson(1) - shell.key(ShellKey.Ok) - assertEquals("home", shell.route) - verify { navigation.replace(Destination.Home()) } - withTimeout(5_000) { switched.await() } - model.load(server, mindel) - requireNotNull(model.onForeground(server, mindel)).join() - release.countDown() - assertSame(shell, session.state) - assertEquals("mindel", model.signedInPerson) - assertEquals(mindel.id.toString(), shell.person.jellyfinUserId) - assertEquals("Mindel", shell.person.name) - assertEquals("mindel", conductarr.identity.value!!.operator!!.requester) - assertEquals("home", shell.route) - } finally { - release.countDown() - model.viewModelScope.cancel() - } - } - } - ConductarrPreference.updateBaseUrl(previous) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt deleted file mode 100644 index 9809967f..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.os.Looper -import androidx.activity.ComponentActivity -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import io.mockk.coEvery -import io.mockk.mockk -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.cancel -import org.junit.After -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.RecoveryMessage -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitleReadStatus -import org.justinvisser.wolfskin.design.TitleSheet -import org.justinvisser.wolfskin.design.TitleTrack -import org.justinvisser.wolfskin.design.TitleVersion -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows -import org.robolectric.annotation.Config -import java.util.UUID - -/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class TitleAsyncFocusTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var viewModel: WolfskinTitleViewModel - - @After fun dispose() { - if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() - } - - @Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() { - val id = UUID.fromString("00000000-0000-0000-0000-000000000012") - val ready = CompletableDeferred<TitlePageData>() - val facade = mockk<TitleReadFacade>() - coEvery { facade.readSaved(id, any()) } returns null - coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) - val person = ShellPerson("justin", "Justin", "J", false) - val route = titleRoute(id.toString()) - val shell = ShellState(listOf(person), person.key).apply { - pickerVisible = false - pickerLaunch = false - enterDestination(route) - } - val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } - rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } - rule.waitForIdle() - assertTrue(shell.navFocused) - press(Key.DirectionDown) - assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) - - val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) - rule.runOnIdle { - ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate")))) - } - rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused } - press(Key.DirectionRight) - val state = requireNotNull(viewModel.title) - assertEquals("version", state.options[state.column].id) - press(Key.DirectionCenter) - assertEquals(TitleSheet.Version, state.sheet) - rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } - assertNull(state.sheet) - press(Key.DirectionRight) - assertEquals("tracks", state.options[state.column].id) - assertFalse(shell.navFocused) - } - - @Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() { - val id = UUID.fromString("00000000-0000-0000-0000-000000000013") - val reply = CompletableDeferred<org.justinvisser.wolfskin.tv.read.PersonRead>() - val facade = mockk<TitleReadFacade>() - coEvery { facade.readPersonSaved(id) } returns null - coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) - val viewer = ShellPerson("justin", "Justin", "J", false) - val route = personRoute(id.toString()) - val shell = ShellState(listOf(viewer), viewer.key).apply { - pickerVisible = false - pickerLaunch = false - enterDestination(route) - } - rule.setContent { WolfskinTheme { - TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) - } } - rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } - rule.waitForIdle() - assertTrue(shell.navFocused) - press(Key.DirectionDown) - assertTrue(shell.navFocused) - rule.runOnIdle { - reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( - org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), - listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")), - )) - } - rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } - assertNotNull("A real person reply must populate the performer destination", viewModel.person) - rule.waitForIdle() - assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) - assertEquals(0, viewModel.person!!.index) - press(Key.DirectionRight) - assertEquals(1, viewModel.person!!.index) - assertFalse(shell.navFocused) - } - - @Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() { - val id = UUID.fromString("00000000-0000-0000-0000-000000000014") - val reads = java.util.concurrent.atomic.AtomicInteger() - val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"), - connection = TitleConnection.Offline, savedAt = "20:42") - val facade = mockk<TitleReadFacade>() - coEvery { facade.readSaved(id, any()) } returns saved - coEvery { facade.read(id, any(), any()) } coAnswers { - reads.incrementAndGet() - throw java.io.IOException("offline") - } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk<TitleFixtureProvider>(), mockk<NavigationManager>(relaxed = true), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk<com.github.damontecres.wholphin.data.ServerRepository>(relaxed = true)) - val viewer = ShellPerson("justin", "Justin", "J", false) - val route = titleRoute(id.toString()) - val shell = ShellState(listOf(viewer), viewer.key).apply { - pickerVisible = false; pickerLaunch = false; enterDestination(route) - } - rule.setContent { WolfskinTheme { - TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) - } } - rule.waitUntil(timeoutMillis = 5000) { reads.get() == 1 && viewModel.title?.toast != null } - rule.waitForIdle() - assertEquals("20:42", shell.navSavedAt) - assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) - rule.runOnIdle { viewModel.title!!.toast = null } - press(Key.DirectionCenter) - rule.waitUntil(timeoutMillis = 5000) { - Shadows.shadowOf(Looper.getMainLooper()).idle() - reads.get() == 2 && viewModel.title?.toast != null - } - rule.waitForIdle() - assertEquals("Saved title", viewModel.title!!.data.item.name) - assertEquals("20:42", shell.navSavedAt) - assertEquals(RecoveryMessage.StillUnreachable, viewModel.title!!.toast) - assertFalse(shell.navFocused) - rule.onNodeWithTag("recovery-toast").assertIsDisplayed() - } - - private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt deleted file mode 100644 index 55f9d747..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ /dev/null @@ -1,219 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.graphics.toArgb -import com.github.takahirom.roborazzi.captureRoboImage -import org.json.JSONObject -import org.junit.Before -import org.junit.After -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.LocalTitleBitmaps -import org.justinvisser.wolfskin.design.PersonState -import org.justinvisser.wolfskin.design.PersonSurface -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitleReadStatus -import org.justinvisser.wolfskin.design.TitleState -import org.justinvisser.wolfskin.design.TitleSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.io.File -import java.util.Locale - -/** Frozen local art feeds the shipped Compose surface; no test can call a live server. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleCaptureTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var fixture: TitleFixture - private var previousLocale = Locale.getDefault() - - @Before fun fullscreen() { - previousLocale = Locale.getDefault() - Locale.setDefault(Locale.US) - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) - } - @After fun restoreLocale() { Locale.setDefault(previousLocale) } - - private fun capture(number: Int, title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }, configure: (TitleState, ShellState) -> Unit = { _, _ -> }) { - val data = transform(fixture.title(title, person)) - val shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } - val state = TitleState(data, shell.person) {} - configure(state, shell) - val bitmaps = decodeArt(data) - rule.setContent { - WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) { - TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) - } } - } - if (number == 55) rule.runOnIdle { - shell.view.listState.requestScrollToItem(1, -(WolfskinTokens.Layout.pinnedLine.value * 2).toInt()) - } - save(number) - } - - private fun decodeArt(data: TitlePageData): Map<String, androidx.compose.ui.graphics.ImageBitmap> { - val requests = buildList<Triple<String, Int, Int>> { - data.item.backdrop?.let { add(Triple(it, 1280, 720)) } - if (data.item.backdrop == null || data.item.isPosterBackdrop) data.item.poster?.let { add(Triple(it, 420, 630)) } - data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } - data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } - data.episodes.forEach { item -> - (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } - } - } - return requests.distinctBy { it.first }.associate { (path, width, height) -> - val bitmap = rule.activity.assets.open(path.removePrefix("file:///android_asset/")).use { BitmapFactory.decodeStream(it)!! } - val scale = maxOf(width.toFloat() / bitmap.width, height.toFloat() / bitmap.height).coerceAtMost(1f) - val scaled = Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), true) - if (scaled !== bitmap) bitmap.recycle() - path to scaled.asImageBitmap() - } - } - - private fun save(number: Int) { - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "st-${number.toString().padStart(3, '0')}.png").absolutePath) - val heading = rule.onAllNodesWithTag("title-heading").fetchSemanticsNodes().singleOrNull() - if (heading != null) { - val layouts = mutableListOf<TextLayoutResult>() - rule.onNodeWithTag("title-heading").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } - val layout = layouts.single() - val metrics = JSONObject().put("title", layout.layoutInput.text.text).put("lineCount", layout.lineCount) - .put("lineWidthsPx", org.json.JSONArray(List(layout.lineCount) { layout.getLineRight(it) - layout.getLineLeft(it) })) - .put("ellipsized", org.json.JSONArray(List(layout.lineCount) { layout.isLineEllipsized(it) })) - .put("maxWidthPx", layout.layoutInput.constraints.maxWidth).put("fontSizeSp", layout.layoutInput.style.fontSize.value) - .put("requestedWeight", layout.layoutInput.style.fontWeight?.weight) - .put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom))) - File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2)) - } - recordTextMetrics(directory, number) - recordElements(directory, number) - } - - private fun recordTextMetrics(directory: File, number: Int) { - val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true) - val records = org.json.JSONArray() - elements.fetchSemanticsNodes().forEachIndexed { index, node -> - if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) { - val layouts = mutableListOf<TextLayoutResult>() - elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } - val layout = layouts.single() - records.put(JSONObject().put("text", layout.layoutInput.text.text) - .put("fontSizeSp", layout.layoutInput.style.fontSize.value) - .put("weight", layout.layoutInput.style.fontWeight?.weight) - .put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16)) - .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL) - .put("lineCount", layout.lineCount) - .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))) - .put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span -> - JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight) - }))) - } - } - File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2)) - } - - private fun recordElements(directory: File, number: Int) { - val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag - val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription - val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description) - val records = org.json.JSONArray() - rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - records.put(JSONObject() - .put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL) - .put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL) - .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))) - } - File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2)) - } - - private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) } - private fun open(state: TitleState, shell: ShellState, id: String) { - option(state, id) - state.key(ShellKey.Ok, true, 0, shell) - state.key(ShellKey.Ok, false, 0, shell) - } - - @Test fun st042_heldMovie() = capture(42, "The Girl Who Leapt Through Time") - @Test fun st043_options() = capture(43) { state, _ -> option(state, "version") } - @Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") } - @Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") } - @Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") } - @Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") } - @Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." } - @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } - @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } - @Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") } - @Test fun st052_filmCast() = capture(52) { state, shell -> - state.focus("cast", 0) - state.key(ShellKey.Right, true, 0, shell) - state.key(ShellKey.Right, false, 0, shell) - } - @Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) } - @Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) } - @Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) }) - @Test fun st056_castUnavailable() = capture(56, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Unavailable) }) { state, _ -> state.focus("related", 0) } - @Test fun st057_person() { - val (person, credits) = fixture.person("Tom Holland", "justin") - val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; enterDestination("person/Tom Holland") } - val state = PersonState(person, credits, {}) - val data = TitlePageData(credits.first(), cast = listOf(person), related = credits) - val art = decodeArt(data) - rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { - PersonSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) - } } } - save(57) - } - @Test fun st058_hiddenReceipt() = capture(58, "The Amazing Spider-Man") { state, shell -> open(state, shell, "hide") } - @Test fun st059_hiddenPick() = capture(59, "The Amazing Spider-Man", transform = { it.copy(hidden = true) }) { state, _ -> option(state, "unhide") } - @Test fun st060_titleNav() = capture(60) { _, shell -> shell.focusNav(0) } - @Test fun st061_loading() = capture(61, transform = { it.copy(status = TitleReadStatus.Loading) }) { _, shell -> shell.focusNav(0) } - @Test fun st062_error() = capture(62, transform = { it.copy(status = TitleReadStatus.Unavailable) }) - @Test fun st063_signedOut() = capture(63, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.SignedOut) }) - @Test fun st064_seriesNext() = capture(64, "Andor") - @Test fun st065_resumeEpisode() = capture(65, "episode-sex-and-the-country-2001-s4e9", "mindel") - @Test fun st066_notHeld() = capture(66, "The Amazing Spider-Man") - @Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man") - @Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") }) - @Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) }) - @Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { - it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList())) - }) - @Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) } - @Test fun st106_heldRatings() = capture(106, "American Gangster", "shared") - @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") - @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") - @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") - @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt deleted file mode 100644 index 6df8426d..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ /dev/null @@ -1,345 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performMouseInput -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.ShellAction -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleAction -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleReadStatus -import org.justinvisser.wolfskin.design.TitleSheet -import org.justinvisser.wolfskin.design.TitleState -import org.justinvisser.wolfskin.design.TitleSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class TitleKeysTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var fixture: TitleFixture - private val effects = mutableListOf<TitleAction>() - private val shellEffects = mutableListOf<ShellAction>() - private lateinit var state: TitleState - private lateinit var shell: ShellState - private lateinit var paths: List<JSONObject> - - @Before fun fixtures() { - fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) - val data = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } - paths = List(data.length()) { data.getJSONObject(it) } - } - - private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) { - shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) } - state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add) - rule.setContent { WolfskinTheme { - BackHandler { state.back(shell) } - TitleSurface(state, shell) - } } - } - - private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } - private fun replay(end: String): Int { - val path = paths.single { it.getString("end") == end } - render(path.getString("title")) - val keys = path.getJSONArray("keys") - var presses = 0 - for (index in path.getInt("entryPresses") until keys.length()) { - val key = when (keys.getString(index)) { - "UP" -> Key.DirectionUp - "DOWN" -> Key.DirectionDown - "LEFT" -> Key.DirectionLeft - "RIGHT" -> Key.DirectionRight - "OK" -> Key.DirectionCenter - else -> error("Unknown replay key") - } - press(key) - presses++ - } - return presses - } - - @Test fun titleToNavOnePressFromEntry() { - assertEquals(1, replay("nav")) - assertTrue(shell.navFocused) - assertEquals(shell.currentTab, shell.navIndex) - } - - @Test fun switchPersonFivePressesFromTitleEntry() { - assertEquals(5, replay("person-mindel")) - assertEquals("mindel", shell.person.key) - assertEquals("home", shell.route) - assertFalse(shell.pickerVisible) - assertTrue(shellEffects.last() is ShellAction.Person) - } - - @Test fun lockeSecondVersionSixPressesFromTitleEntry() { - assertEquals(6, replay("play-version-2")) - assertEquals("v2", state.versionId) - assertNull(state.sheet) - assertEquals("play", state.options[state.column].id) - assertTrue(effects.last() is TitleAction.Play) - assertEquals("Locke", (effects.last() as TitleAction.Play).item.name) - } - - @Test fun lockeCastTwoPressesFromTitleEntry() { - assertEquals(2, replay("person-Tom Hardy")) - assertEquals("cast", state.row) - assertEquals("Tom Hardy", (effects.single() as TitleAction.Person).person.name) - } - - @Test fun sheetBackRestoresItsOpeningOptionAndChosenVersion() { - render() - press(Key.DirectionRight) - press(Key.DirectionCenter) - assertEquals(TitleSheet.Version, state.sheet) - press(Key.DirectionDown) - press(Key.DirectionCenter) - assertEquals("v2", state.versionId) - assertEquals("Version", state.options[state.column].label) - press(Key.DirectionCenter) - assertEquals(1, state.sheetIndex) - press(Key.Back) - assertNull(state.sheet) - assertEquals("Version", state.options[state.column].label) - assertFalse(shell.navFocused) - } - - @Test fun trackChoiceStaysInSheetAndOffRemainsAnExplicitChoice() { - render() - press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter) - assertEquals(TitleSheet.Tracks, state.sheet) - press(Key.DirectionDown); press(Key.DirectionCenter) - assertEquals(1, state.audioIndex) - assertEquals(TitleSheet.Tracks, state.sheet) - press(Key.DirectionDown); press(Key.DirectionDown); press(Key.DirectionCenter) - assertEquals(2, state.subtitleIndex) - press(Key.DirectionUp); press(Key.DirectionCenter) - assertEquals(-1, state.subtitleIndex) - press(Key.Back) - assertNull(state.sheet) - assertEquals("tracks", state.options[state.column].id) - } - - @Test fun castLoadingAndUnavailableAreNeverFocusable() { - render(transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) }) - press(Key.DirectionDown) - assertEquals("related", state.row) - assertFalse(state.rows.contains("cast")) - } - - @Test fun wholeSeriesRequiresConfirmationButEpisodesDoNot() { - render("Andor") - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) } - press(Key.DirectionCenter) - assertEquals(TitleSheet.ConfirmWatched, state.sheet) - assertFalse(state.watched) - press(Key.DirectionRight); press(Key.DirectionCenter) - assertFalse(state.watched) - assertTrue(effects.none { it is TitleAction.Watched }) - press(Key.DirectionCenter); press(Key.DirectionCenter) - assertTrue(state.watched) - assertTrue(effects.last() is TitleAction.Watched) - } - - @Test fun titlesNobodyHoldsHaveNoTrailerUntilTheCatalogueFieldExists() { - render("The Amazing Spider-Man") - assertTrue(state.options.none { it.id == "trailer" }) - assertEquals("Request", state.options.first().label) - rule.runOnIdle { state.update(state.data.copy(connection = TitleConnection.SignedOut)) } - assertEquals(listOf("retry"), state.options.map { it.id }) - } - - @Test fun hiddenReceiptUndoRestoresTitleAndOptionInPlace() { - render("The Amazing Spider-Man") - press(Key.DirectionRight); press(Key.DirectionRight); press(Key.DirectionCenter) - assertTrue(state.data.hidden) - assertEquals(TitleSheet.HiddenReceipt, state.sheet) - press(Key.DirectionCenter) - assertFalse(state.data.hidden) - assertNull(state.sheet) - assertEquals("hide", state.options[state.column].id) - } - - @Test fun heldOkCannotChooseTheVersionInTheSheetItOpened() { - render() - press(Key.DirectionRight) - rule.runOnIdle { - rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0)) - repeat(5) { index -> rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, index + 1)) } - rule.activity.dispatchKeyEvent(android.view.KeyEvent(0, 0, android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 0)) - } - assertEquals(TitleSheet.Version, state.sheet) - assertTrue(effects.none { it is TitleAction.Version }) - } - - @Test fun episodeWatchedToggleKeepsItsIdentityAndDoesNotAskAboutTheSeries() { - val episode = requireNotNull(fixture.title("Andor", "justin").playTarget) - render(episode.id) - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "watched" }) } - press(Key.DirectionCenter) - assertNull(state.sheet) - val change = effects.single() as TitleAction.Watched - assertEquals(episode.id, change.item.id) - assertEquals(!episode.watched, change.watched) - } - - @Test fun aPendingSeasonSkipsItsEpisodeSkeletons() { - render("Andor") - rule.runOnIdle { state.showSeasonLoading() } - press(Key.DirectionDown); press(Key.DirectionDown) - assertEquals("cast", state.row) - rule.runOnIdle { state.restoreSeason() } - press(Key.DirectionUp) - assertEquals("episodes", state.row) - } - - @Test fun aFailedOlderVersionWriteCannotUndoTheNewerChoice() { - render() - press(Key.DirectionRight); press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) - val first = effects.last() as TitleAction.Version - press(Key.DirectionCenter); press(Key.DirectionUp); press(Key.DirectionCenter) - val second = effects.last() as TitleAction.Version - rule.runOnIdle { state.rejectPlayback(first); state.confirmPlayback(second) } - assertEquals("v1", state.versionId) - press(Key.DirectionCenter); press(Key.DirectionDown); press(Key.DirectionCenter) - val failed = effects.last() as TitleAction.Version - rule.runOnIdle { state.rejectPlayback(failed) } - assertEquals("v1", state.versionId) - } - - @Test fun aNewRowUsesTheNearestHorizontalCardBeforeItsOwnRememberedCard() { - render() - repeat(4) { press(Key.DirectionRight) } - val x = rule.onNodeWithTag("title-option-trailer").fetchSemanticsNode().boundsInRoot.center.x - val cast = state.data.cast.take(WolfskinTokens.Card.personSlots) - val nearest = cast.indices.minBy { index -> - kotlin.math.abs(rule.onNodeWithTag("title-cast-${cast[index].id}").fetchSemanticsNode().boundsInRoot.center.x - x) - } - press(Key.DirectionDown) - assertEquals(nearest, state.column) - press(Key.DirectionRight) - val remembered = state.column - press(Key.DirectionUp); press(Key.DirectionDown) - assertEquals(remembered, state.column) - } - - @Test fun wheelThenRemoteLandsOnTheFirstVisibleFocusableRow() { - render() - rule.onNodeWithTag("title-page").performMouseInput { scroll(800f) } - val expected = rule.runOnIdle { - shell.view.listState.layoutInfo.visibleItemsInfo.first { - it.key in state.rows && it.offset + it.size > WolfskinTokens.Layout.pinnedLine.value * 2 - }.key as String - } - assertNotEquals("header", expected) - press(Key.DirectionDown) - assertEquals(expected, state.row) - } - @Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() { - render("Andor") - val layouts = mutableListOf<TextLayoutResult>() - rule.onNodeWithText("Add for someone", useUnmergedTree = true) - .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } - val layout = layouts.single() - assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth) - assertEquals(1, layout.lineCount) - } - - @Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() { - render(transform = { it.copy(status = TitleReadStatus.Loading) }) - rule.onNodeWithText("Cast").assertDoesNotExist() - press(Key.DirectionDown) - assertTrue(shell.navFocused) - assertEquals("header", state.row) - rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } - assertEquals(listOf(TitleAction.Pop), effects) - } - - - @Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() { - render("The Amazing Spider-Man", transform = { - it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0), - cast = emptyList(), related = emptyList()) - }) - val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() { - render() - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) } - press(Key.DirectionCenter) - val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 - assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) - val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f) - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun filmHeaderKeepsItsFixedCssStack() { - render() - assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f) - assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f) - assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) - } - - @GraphicsMode(GraphicsMode.Mode.NATIVE) - @Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() { - render("Andor") - assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) - } - - @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { - render(transform = { data -> - val source = data.versions.first().copy( - audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") }, - subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") }, - ) - data.copy(versions = listOf(source)) - }) - rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) } - press(Key.DirectionCenter) - val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom - val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done" - labels.forEachIndexed { index, label -> - if (index > 0) press(Key.DirectionDown) - rule.waitForIdle() - val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertTrue("$label must have visible height: $bounds", bounds.height > 20) - assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom) - assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0) - } - press(Key.DirectionCenter) - assertNull(state.sheet) - assertFalse(shell.navFocused) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt deleted file mode 100644 index 07636e5a..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLaunchKeysTest.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.* -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Full launch paths render the real picker, Home hero/shelves, title and person surfaces. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class TitleLaunchKeysTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - private lateinit var fixture: TitleFixture - private lateinit var home: HomeFixture - private lateinit var shell: ShellState - private var title by mutableStateOf<TitleState?>(null) - private var person by mutableStateOf<PersonState?>(null) - private val actions = mutableListOf<TitleAction>() - private val shellActions = mutableListOf<ShellAction>() - private lateinit var paths: List<JSONObject> - - @Before fun launch() { - fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) - home = HomeFixture(rule.activity) - val source = javaClass.getResourceAsStream("/title-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } - paths = List(source.length()) { source.getJSONObject(it) } - shell = ShellState(fixture.people, "justin") { action -> - shellActions.add(action) - if (action is ShellAction.Person) { title = null; person = null } - } - rule.setContent { WolfskinTheme { - val performer = person - val page = title - val snapshot = remember(shell.person.key) { home.snapshot(shell.person.key) } - when { - performer != null -> PersonSurface(performer, shell) - page != null -> TitleSurface(page, shell) - else -> ShellSurface(shell) { modifier -> - HomeScreen(shell, snapshot, modifier) { action -> - if (action is HomeAction.Details) openTitle(action.title.id) - } - } - } - } } - } - - private fun openTitle(id: String) { - val data = fixture.title(id, shell.person.key) - title = TitleState(data, shell.person, ::act) - person = null - shell.enterDestination(titleRoute(id)) - } - - private fun act(action: TitleAction) { - actions.add(action) - when (action) { - is TitleAction.Person -> { - val (actor, credits) = fixture.person(action.person.id, shell.person.key) - person = PersonState(actor, credits, ::act) - shell.enterDestination(personRoute(actor.id)) - } - is TitleAction.OpenTitle -> openTitle(action.item.id) - else -> Unit - } - } - - private fun replay(end: String, expectedPresses: Int) { - val path = paths.single { it.getString("end") == end } - val keys = path.getJSONArray("keys") - assertEquals(expectedPresses, path.getInt("presses")) - assertEquals(expectedPresses, keys.length()) - for (index in 0 until keys.length()) { - val key = when (keys.getString(index)) { - "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown - "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight - "OK" -> Key.DirectionCenter - else -> error("Unknown launch replay key") - } - rule.onRoot().performKeyInput { pressKey(key) } - } - } - - @Test fun titleToNavTakesFourPressesFromTheRealHomeHero() { - replay("nav", 4) - assertNotNull(title) - assertTrue(shell.navFocused) - assertEquals(shell.currentTab, shell.navIndex) - } - - @Test fun switchingPersonFromATitleTakesEightLaunchPresses() { - replay("person-mindel", 8) - assertEquals("mindel", shell.person.key) - assertFalse(shell.pickerVisible) - assertEquals("home", shell.route) - assertTrue(shellActions.last() is ShellAction.Person) - } - - @Test fun lockeVersionTwoAndPlayTakesFifteenLaunchPresses() { - replay("play-version-2", 15) - val page = requireNotNull(title) - assertEquals("Locke", page.data.item.name) - assertEquals("v2", page.versionId) - assertNull(page.sheet) - assertEquals("play", page.options[page.column].id) - assertEquals("Locke", (actions.last() as TitleAction.Play).item.name) - } - - @Test fun lockeToTomHardyTakesElevenLaunchPresses() { - replay("person-Tom Hardy", 11) - assertEquals("Tom Hardy", requireNotNull(person).person.name) - assertEquals(0, person!!.index) - assertFalse(shell.navFocused) - assertTrue(actions.last() is TitleAction.Person) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt deleted file mode 100644 index e9462bff..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onLast -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.text.TextLayoutResult -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.LibraryRating -import org.justinvisser.wolfskin.design.TitleState -import org.justinvisser.wolfskin.design.TitleSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode -import java.util.Locale - -/** Native text layout proves locale formatting still fits the focused poster's one-line plate. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class TitleLocaleTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - private fun render(locale: Locale, expected: String) { - val previous = Locale.getDefault() - try { - Locale.setDefault(locale) - val person = ShellPerson("justin", "Justin", "J", false) - val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8))) - val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") } - val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {} - state.focus("related", 0) - rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } - rule.mainClock.advanceTimeBy(1000) - val layouts = mutableListOf<TextLayoutResult>() - rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } - assertEquals(1, layouts.single().lineCount) - assertFalse(layouts.single().didOverflowWidth) - } finally { Locale.setDefault(previous) } - } - - @Test fun dutchScoreUsesCommaOnTheNativePlate() = render(Locale.forLanguageTag("nl-NL"), "7,8") - @Test fun englishScoreUsesPointOnTheNativePlate() = render(Locale.US, "7.8") -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt deleted file mode 100644 index fde2ff9c..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ /dev/null @@ -1,239 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.content.Context -import com.github.damontecres.wholphin.WholphinApplication -import com.github.damontecres.wholphin.data.ItemPlaybackRepository -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.JellyfinServer -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.ImageUrlService -import com.github.damontecres.wholphin.ui.successQueryResult -import com.github.damontecres.wholphin.ui.successResponse -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.unmockkObject -import kotlinx.coroutines.test.runTest -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.operations.ItemsApi -import org.jellyfin.sdk.api.operations.TvShowsApi -import org.jellyfin.sdk.api.operations.UserLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import com.github.damontecres.wholphin.test.TestTracks -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.request.GetEpisodesRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.junit.Assert.* -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleState -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.RuntimeEnvironment -import java.util.UUID - -/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class TitleReadFacadeTest { - @get:Rule val directory = TemporaryFolder() - private val library = mockk<UserLibraryApi>() - private val items = mockk<ItemsApi>() - private val shows = mockk<TvShowsApi>() - private val api = mockk<ApiClient>() - private val playback = mockk<ItemPlaybackRepository>() - private val repository = mockk<ServerRepository>() - private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - private lateinit var facade: TitleReadFacade - private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000001") - private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002") - private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") - private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004") - private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "") - - @Before fun setup() { - // BaseItem formats runtime using application resources; no Hilt/network application is started. - val app = mockk<WholphinApplication> { - every { resources } returns RuntimeEnvironment.getApplication().resources - } - mockkObject(WholphinApplication.Companion) - every { WholphinApplication.instance } returns app - every { api.userLibraryApi } returns library - every { api.itemsApi } returns items - every { api.tvShowsApi } returns shows - every { repository.currentServer } returns null - every { repository.currentUser } returns null - coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null - coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList()) - val context = mockk<Context> { every { filesDir } returns directory.root } - facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context) - } - - @After fun teardown() { - unmockkObject(WholphinApplication.Companion) - } - - private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto { - val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo() - val streams = source.mediaStreams.orEmpty().map { stream -> - when (stream.type) { - MediaStreamType.VIDEO -> stream.copy(index = 0, displayTitle = "1080p") - MediaStreamType.AUDIO -> stream.copy(index = 3, displayTitle = "English", isDefault = true) - MediaStreamType.SUBTITLE -> stream.copy(index = 8, displayTitle = "Dutch") - else -> error("Unexpected track in the SDK fixture builder") - } - } - return BaseItemDto(id = id, name = "A real file", type = kind, runTimeTicks = 6_000_000_000, - mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams))) - } - - @Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) - coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult( - listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42")))) - val page = facade.read(firstId, null, preferences) - assertEquals(42L, page.item.tmdbId) - assertEquals(firstId.toString(), page.watchTarget.id) - assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id) - FakeConductarr().use { fake -> - ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session -> - session.select("server:shared", SHARED_TOKEN, fake.url) - session.authenticate() - val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {} - ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId), - org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) } - assertEquals(listOf("header", "cast", "related"), screen.rows) - assertEquals("Sam Rockwell", screen.data.cast.single().name) - assertEquals(firstId.toString(), screen.target?.id) - assertTrue(screen.data.item.held) - } - } - } - - @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) - facade.read(firstId, null, preferences) - coEvery { library.getItem(any()) } throws java.io.IOException("offline") - val saved = facade.readSaved(firstId, preferences)!! - val screen = TitleState(saved, ShellPerson("justin", "Justin", "J", false)) {} - assertEquals("MKV", saved.versions.single().container) - assertEquals(3, screen.audio?.index) - assertEquals(listOf(8), saved.versions.single().subtitles.map { it.index }) - assertEquals(TitleConnection.SignedOut, saved.connection) - assertTrue(saved.item.ratings.isEmpty()) - assertEquals(firstId.toString(), saved.watchTarget.id) - } - - @Test fun diskCopiesNeverCrossViewerIdentity() = runTest { - val userA = mockk<JellyfinUser> { every { id } returns firstId } - val userB = mockk<JellyfinUser> { every { id } returns secondId } - every { repository.currentUser } returns userA - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) - facade.read(firstId, null, preferences) - every { repository.currentUser } returns userB - assertNull(facade.readSaved(firstId, preferences)) - every { repository.currentUser } returns userA - assertEquals("A real file", facade.readSaved(firstId, preferences)?.item?.name) - } - - @Test fun personBiographyAndContinuousCreditsRemainReadableFromDiskWithoutAService() = runTest { - val actor = BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Actor", overview = "Actual biography") - coEvery { library.getItem(firstId) } returns successResponse(actor) - coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId))) - facade.readPerson(firstId, 0) - coEvery { library.getItem(any()) } throws java.io.IOException("offline") - coEvery { items.getItems(any<GetItemsRequest>()) } throws java.io.IOException("offline") - val saved = facade.readPersonSaved(firstId)!! - assertEquals("Actual biography", saved.person.biography) - assertEquals(secondId.toString(), saved.credits.single().id) - assertEquals("A real file", saved.credits.single().name) - } - - @Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest { - val userA = mockk<JellyfinUser> { every { id } returns firstId } - val userB = mockk<JellyfinUser> { every { id } returns secondId } - val serverA = mockk<JellyfinServer> { every { id } returns seriesId } - val serverB = mockk<JellyfinServer> { every { id } returns seasonId } - every { repository.currentUser } returns userA - every { repository.currentServer } returns serverA - coEvery { library.getItem(firstId) } returns successResponse( - BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Saved actor", overview = "Private biography")) - coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(file(secondId))) - facade.readPerson(firstId, 0) - every { repository.currentUser } returns userB - assertNull(facade.readPersonSaved(firstId)) - every { repository.currentUser } returns userA - every { repository.currentServer } returns serverB - assertNull(facade.readPersonSaved(firstId)) - every { repository.currentServer } returns serverA - val saved = requireNotNull(facade.readPersonSaved(firstId)) - assertEquals("Private biography", saved.person.biography) - assertEquals(secondId.toString(), saved.credits.single().id) - } - - @Test fun cataloguePlaybackLookupRejectsAnotherMediaTypeOrTmdbIdentity() = runTest { - var payload = """{"Items":[{"Id":"$firstId","Type":"Series","ProviderIds":{"Tmdb":"42"}}]}""" - coEvery { api.request(any(), any(), any(), any(), any()) } coAnswers { - org.jellyfin.sdk.api.client.RawResponse(org.json.JSONObject(payload).put("TotalRecordCount", 1).put("StartIndex", 0).toString().toByteArray(), 200, emptyMap()) - } - assertNull(facade.findHeld(42, "movie")) - payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"43"}}]}""" - assertNull(facade.findHeld(42, "movie")) - payload = """{"Items":[{"Id":"$firstId","Type":"Movie","ProviderIds":{"Tmdb":"42"}}]}""" - assertEquals(firstId, facade.findHeld(42, "movie")) - } - - @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest { - val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) - val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1) - val series = BaseItemDto(id = seriesId, name = "Series", type = BaseItemKind.SERIES) - val secondSeason = BaseItemDto(id = seasonId, type = BaseItemKind.SEASON, indexNumber = 2) - coEvery { library.getItem(firstId) } returns successResponse(episode) - coEvery { library.getItem(seriesId) } returns successResponse(series) - coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(secondSeason)) - coEvery { shows.getEpisodes(any<GetEpisodesRequest>()) } returns successQueryResult(listOf(other)) - val data = facade.read(firstId, 2, preferences) - assertEquals(seriesId.toString(), data.item.id) - assertEquals(firstId.toString(), data.playTarget?.id) - assertEquals(firstId.toString(), data.watchTarget.id) - assertEquals(listOf(secondId.toString()), data.episodes.map { it.id }) - assertEquals(listOf(2), data.seasonNumbers) - assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) - } - - @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) - coEvery { playback.savePlayVersion(any(), any()) } returns null - val data = facade.read(firstId, null, preferences) - facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single())) - coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) } - } - @Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest { - coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) - coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns - com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId) - val data = facade.read(firstId, null, preferences) - facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1)) - coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) } - coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) } - facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8)) - coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) } - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt deleted file mode 100644 index afa137ee..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.content.Context -import android.content.Intent -import android.content.pm.ActivityInfo -import android.content.pm.ResolveInfo -import android.net.Uri -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config - -/** Browser handlers must never become a fallback for a missing YouTube app. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class TitleTrailerTest { - private val context = ApplicationProvider.getApplicationContext<Context>() - private val url = "https://www.youtube.com/watch?v=abcdefghijk" - - @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { - register(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "test.browser") - assertFalse(TitleTrailer(context).open(url)) - assertNull(shadowOf(context as Application).nextStartedActivity) - } - - @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { - register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:abcdefghijk")), "test.youtube") - assertTrue(TitleTrailer(context).open(url)) - val launched = shadowOf(context as Application).nextStartedActivity - assertEquals("test.youtube", launched.`package`) - assertEquals("vnd.youtube", launched.data?.scheme) - assertEquals("abcdefghijk", launched.data?.schemeSpecificPart) - } - - @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { - register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser") - assertFalse(TitleTrailer(context).open("https://trailers.example/video")) - assertNull(shadowOf(context as Application).nextStartedActivity) - } - - private fun register(intent: Intent, name: String) { - val info = ResolveInfo().apply { - activityInfo = ActivityInfo().apply { packageName = name; this.name = "$name.Player"; exported = true } - } - shadowOf(context.packageManager).addResolveInfoForIntent(intent, info) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt deleted file mode 100644 index 5c55f1fc..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.graphics.toPixelMap -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotFocused -import androidx.compose.ui.test.captureToImage -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import androidx.compose.ui.unit.dp -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.BackdropStyle -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropResult -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.takahirom.roborazzi.captureRoboImage -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import java.io.File -import java.util.UUID -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.ShellSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The rollout-off path must preserve native content focus and its backdrop beneath the shell. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class UpstreamContentFocusTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @Before fun prepare() { - WolfskinScreenPreference.initialize(rule.activity) - WolfskinScreenPreference.enabled = false - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - @After fun restore() { WolfskinScreenPreference.enabled = true } - - private fun shellState() = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply { - pickerVisible = false - navFocused = false - } - - @Test fun switchOffUpMovesBetweenContentRowsBeforeEnteringNav() { - val state = shellState() - val first = FocusRequester() - val second = FocusRequester() - rule.setContent { - val upstream = resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, emptyMap()) - WolfskinTheme { - ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier -> - Column(contentModifier.padding(start = 100.dp, top = 150.dp), verticalArrangement = Arrangement.spacedBy(32.dp)) { - Box(Modifier.size(200.dp, 60.dp).testTag("upstream-first").focusRequester(first).focusable()) - Box(Modifier.size(200.dp, 60.dp).testTag("upstream-second").focusRequester(second).focusable()) - } - } - } - } - rule.runOnIdle { second.requestFocus() } - rule.onNodeWithTag("upstream-second").assertIsFocused() - rule.onNodeWithTag("upstream-second").performKeyInput { pressKey(Key.DirectionUp) } - rule.onNodeWithTag("upstream-first").assertIsFocused() - rule.runOnIdle { assertFalse("Up from the second row must not enter nav", state.navFocused) } - rule.onNodeWithTag("upstream-first").performKeyInput { pressKey(Key.DirectionUp) } - rule.runOnIdle { assertTrue("Only the first row exits upward to nav", state.navFocused) } - rule.onNodeWithTag("upstream-first").assertIsNotFocused() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } - rule.onNodeWithTag("upstream-first").assertIsFocused() - } - - @Test fun switchOffShowsRealUpstreamBackdropAndSwitchOnRestoresOwnedBackground() { - val state = shellState() - val model = mockk<WolfskinShellViewModel>(relaxed = true) - every { model.session } returns WolfskinSession().also { it.state = state } - every { model.conductarr } returns ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) - every { model.libraryDestinations } returns emptyMap() - // An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads. - every { model.signedInPerson } returns "unavailable" - every { model.backdropFlow } returns MutableStateFlow(BackdropResult("fixture", null, Color.Red, Color.Red, Color.Red)) - coEvery { model.load(any(), any()) } returns Unit - val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) - val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val defaults = AppPreferences.getDefaultInstance() - val preferences = UserPreferences(defaults.toBuilder().setInterfacePreferences(defaults.interfacePreferences.toBuilder().setBackdropStyle(BackdropStyle.BACKDROP_DYNAMIC_COLOR)).build(), null) - val navigation = NavigationManager() - rule.setContent { WolfskinApp(server, user, navigation, preferences, Modifier.fillMaxSize(), model) } - rule.mainClock.advanceTimeBy(1500) - rule.waitForIdle() - val off = rule.onRoot().captureToImage().toPixelMap()[1700, 900] - assertTrue("The real dynamic backdrop must remain visible through native content", off.red > .4f && off.green < .1f && off.blue < .1f) - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "review-upstream-off.png").absolutePath) - rule.runOnIdle { WolfskinScreenPreference.enabled = true } - rule.mainClock.advanceTimeBy(1500) - rule.waitForIdle() - rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed() - val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900] - val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg - org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f) - org.junit.Assert.assertEquals("Owned background green", background.green, on.green, 1f / 255f) - org.junit.Assert.assertEquals("Owned background blue", background.blue, on.blue, 1f / 255f) - rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath) - } -} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt deleted file mode 100644 index 9e4d1118..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.app.Application -import android.graphics.Bitmap -import androidx.activity.ComponentActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import coil3.request.SuccessResult -import coil3.imageLoader -import coil3.SingletonImageLoader -import coil3.request.CachePolicy -import coil3.request.ImageRequest -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.ui.CoilConfig -import io.mockk.every -import io.mockk.mockk -import java.io.ByteArrayOutputStream -import java.util.UUID -import java.util.concurrent.CopyOnWriteArrayList -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import okhttp3.OkHttpClient -import okio.Buffer -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.justinvisser.wolfskin.design.WolfskinImages -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class WolfskinArtworkTest { - @get:Rule val rule = createAndroidComposeRule<ComponentActivity>() - - @OptIn(coil3.annotation.DelicateCoilApi::class) - @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { - MockWebServer().use { jellyfin -> - MockWebServer().use { artwork -> - jellyfin.start(); artwork.start() - val context = rule.activity - val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\"" - val authenticated = OkHttpClient.Builder().addInterceptor { chain -> - chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build()) - }.build() - val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null) - val repository = mockk<ServerRepository> { every { currentServer } returns server } - val network = WolfskinArtwork(authenticated, repository) - val bytes = ByteArrayOutputStream().also { - Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it) - }.toByteArray() - jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) - val urls = listOf(jellyfin.url("/jellyfin.png"), - artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), - artwork.url("/another-port.png")) - val results = CopyOnWriteArrayList<coil3.request.ImageResult>() - val previousParent = context.applicationContext.imageLoader - try { - // Other screen tests may already have created Coil's process singleton. - SingletonImageLoader.reset() - rule.setContent { - CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) - LaunchedEffect(network) { - // Force the upstream authenticated parent to exist before configuring Wolfskin. - context.applicationContext.imageLoader - network.configure(context) - val loader = WolfskinImages.loader(context) - for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString()) - .memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build())) - } - } - rule.waitUntil(30_000) { - org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() - results.size == urls.size - } - results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) } - assertEquals(credential, jellyfin.takeRequest().headers["Authorization"]) - assertNull(artwork.takeRequest().headers["Authorization"]) - assertNull(artwork.takeRequest().headers["Authorization"]) - } finally { - WolfskinImages.loader(context).shutdown() - val testParent = context.applicationContext.imageLoader - SingletonImageLoader.setUnsafe(previousParent) - testParent.shutdown() - authenticated.dispatcher.executorService.shutdown() - authenticated.connectionPool.evictAll() - } - } - } - } -} diff --git a/app/src/test/resources/conductarr/wire.json b/app/src/test/resources/conductarr/wire.json deleted file mode 100644 index 2893b49a..00000000 --- a/app/src/test/resources/conductarr/wire.json +++ /dev/null @@ -1,621 +0,0 @@ -{ - "operator": { - "capabilities": { - "administer": false, - "manage_library": false, - "manage_memberships": false, - "recommendation_profiles": [ - "shared" - ], - "request_media": true, - "requesters": [ - "shared", - "mindel" - ], - "watch_users": [ - "shared" - ] - }, - "name": "Shared", - "recommendation_profile": "shared", - "requester": "shared", - "role": "household", - "subject": "jellyfin:shared", - "watch_user": "shared" - }, - "profiles": { - "profiles": [ - { - "key": "shared", - "name": "Shared" - }, - { - "key": "mindel", - "name": "Mindel" - } - ], - "default_profile": "shared" - }, - "audiences": { - "audiences": [ - { - "is_anime": true, - "libraries": [ - "Shared Anime" - ], - "media_type": "tv", - "owner": "shared", - "users": [ - "shared", - "mindel" - ] - } - ] - }, - "feed": { - "profile": { - "key": "shared", - "name": "Shared" - }, - "projection": { - "coverage": { - "complete": true, - "excluded": 0, - "matched": 1, - "total": 1, - "unresolved": 0 - }, - "facts": [], - "genre_id": 0, - "media_type": "all", - "service_id": 0, - "unresolved": [] - }, - "state": "current", - "outcome": "ranked", - "materialization": "current", - "movies": [ - { - "backdrop_path": "/moon-wide.jpg", - "contributions": [ - { - "added": false, - "media_type": "movie", - "title": "Solaris", - "tmdb_id": 33, - "watched": true - } - ], - "genre_ids": [ - 35 - ], - "media_type": "movie", - "overview": "An astronaut reaches the end of his assignment.", - "poster_path": "/moon.jpg", - "score": 4.2, - "title": "Moon", - "tmdb_id": 42, - "tvdb_id": 0, - "year": 2009 - } - ], - "shows": [], - "expires_at": "2099-11-05T12:00:00Z" - }, - "options": { - "genres": [ - { - "id": 35, - "name": "Comedy" - }, - { - "id": 16, - "name": "Animation" - } - ], - "media_type": "movie", - "observations": [], - "region": "NL", - "services": [ - { - "id": 8, - "name": "Netflix" - } - ], - "state": "observed" - }, - "discover": { - "items": [ - { - "tmdb_id": 43, - "media_type": "movie", - "title": "Arrival", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - ], - "media_type": "movie", - "observations": [], - "page": 1, - "query": "", - "source": "general", - "state": "observed", - "total_pages": 2, - "total_results": 24 - }, - "dismissals": { - "profile": "shared", - "dismissals": [ - { - "hidden_at": "2026-11-05T12:00:00Z", - "hidden_by": "shared", - "media_type": "movie", - "poster_path": "/hidden.jpg", - "title": "Hidden film", - "tmdb_id": 99, - "year": 2017 - } - ] - }, - "ratings": { - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ] - }, - "detail": { - "genres": [ - "Comedy" - ], - "metadata": { - "genre_state": "observed", - "genres": [ - { - "id": 35, - "name": "Comedy" - } - ], - "media_type": "movie", - "provider_state": "observed", - "providers": [], - "region": "NL", - "tmdb_id": 42 - }, - "metadata_state": "observed", - "observations": [], - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ], - "runtime": 97, - "seasons": [ - { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - } - ], - "state": "observed", - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - }, - "enrichment": { - "genres": [ - "Comedy" - ], - "metadata": { - "genre_state": "observed", - "genres": [ - { - "id": 35, - "name": "Comedy" - } - ], - "media_type": "movie", - "provider_state": "observed", - "providers": [], - "region": "NL", - "tmdb_id": 42 - }, - "metadata_state": "observed", - "observations": [], - "ratings": [ - { - "source": "rt_critics", - "state": "observed", - "score": 90, - "status": "Certified Fresh" - }, - { - "source": "rt_audience", - "state": "observed", - "score": 89, - "status": "Upright" - }, - { - "source": "imdb", - "state": "observed", - "score": 7.8, - "votes": 300000 - }, - { - "source": "tmdb", - "state": "observed", - "score": 7.6, - "votes": 4000 - } - ], - "runtime": 97, - "seasons": [ - { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - } - ], - "state": "observed", - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - } - }, - "display": { - "media_type": "movie", - "metadata_state": "observed", - "poster_url": "https://images.example.invalid/moon.jpg", - "title": "Moon", - "tmdb_id": 42 - }, - "cast": { - "tmdb_id": 42, - "media_type": "movie", - "metadata_state": "observed", - "source": "seerr", - "paging": "unpaged", - "credit_context": "movie-character", - "items": [ - { - "performer_id": 216, - "name": "Sam Rockwell", - "character": "Sam Bell", - "credit_id": "cast-216", - "order": 0, - "profile_url": "https://images.example.invalid/sam.jpg" - } - ] - }, - "season": { - "tmdb_id": 44, - "media_type": "tv", - "metadata_state": "observed", - "season": { - "number": 1, - "name": "Season 1", - "overview": "A missing child.", - "episode_count": 10, - "recorded_episodes": 0, - "coverage": "none", - "request_state": "none", - "seerr_state": "untracked" - }, - "episodes": [ - { - "number": 1, - "name": "Secrets", - "overview": "A child disappears.", - "recorded": "missing", - "vote_average": 8.1, - "vote_count": 28 - } - ], - "observations": [] - }, - "performer": { - "performer_id": 216, - "metadata_state": "observed", - "source": "seerr", - "name": "Sam Rockwell", - "biography": "Actor.", - "also_known_as": [] - }, - "filmography": { - "performer_id": 216, - "metadata_state": "observed", - "source": "seerr", - "source_paging": "unpaged", - "completeness": "returned-non-adult-cast", - "page": 1, - "page_size": 20, - "items": [ - { - "title": { - "tmdb_id": 42, - "media_type": "movie", - "title": "Moon", - "overview": "An astronaut reaches the end of his assignment.", - "poster_url": "https://images.example.invalid/moon.jpg", - "backdrop_url": "https://images.example.invalid/moon-wide.jpg", - "release_date": "2009-06-12", - "held": "unheld", - "libraries": [], - "request_state": "none", - "seerr_state": "untracked", - "genre_ids": [ - 35 - ] - }, - "credits": [ - { - "credit_id": "cast-216", - "character": "Sam Bell" - } - ], - "visibility_state": "unknown", - "users": [] - } - ], - "observations": [] - }, - "activity": { - "items": [ - { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "filed_by_you": true, - "id": "request:7", - "media_type": "movie", - "on_behalf": true, - "requester": { - "key": "mindel", - "name": "Mindel" - }, - "seasons": [], - "stages": [ - { - "name": "request", - "source": "conductarr", - "state": "done", - "summary": "Request accepted" - }, - { - "name": "acquisition", - "source": "sonarr", - "state": "in_progress", - "summary": "Downloading" - }, - { - "name": "visibility", - "source": "jellyfin", - "state": "none", - "summary": "Not yet" - }, - { - "name": "playback", - "source": "jellyfin", - "state": "none", - "summary": "Not checked" - } - ], - "title": "Moon", - "tmdb_id": 42, - "blocking_stage": "acquisition" - } - ], - "requester": "all", - "sources": [], - "truncated": false - }, - "readiness": { - "tmdb_id": 42, - "media_type": "movie", - "viewer": { - "key": "mindel", - "name": "Mindel" - }, - "stages": [ - { - "name": "request", - "source": "conductarr", - "state": "done", - "summary": "Request accepted" - }, - { - "name": "acquisition", - "source": "sonarr", - "state": "in_progress", - "summary": "Downloading" - }, - { - "name": "visibility", - "source": "jellyfin", - "state": "none", - "summary": "Not yet" - }, - { - "name": "playback", - "source": "jellyfin", - "state": "none", - "summary": "Not checked" - } - ], - "seerr": { - "state": "observed", - "requests": [] - }, - "coverage": [], - "open_link": { - "state": "missing" - }, - "sources": [] - }, - "requests": { - "requests": [ - { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "attempts": 1, - "coverage": [], - "created_at": "2026-11-05T12:00:00Z", - "id": 7, - "media_type": "movie", - "membership": { - "status": "added" - }, - "on_behalf": true, - "requester": "mindel", - "requester_username": "Mindel", - "seasons": null, - "seerr": { - "outcome": "requested", - "request_id": 9 - }, - "source": "recommendation", - "state": "settled", - "tmdb_id": 42, - "updated_at": "2026-11-05T12:00:00Z" - } - ] - }, - "receipt": { - "actor": { - "name": "Shared", - "requester": "shared", - "role": "household" - }, - "attempts": 1, - "coverage": [], - "created_at": "2026-11-05T12:00:00Z", - "id": 7, - "media_type": "movie", - "membership": { - "status": "added" - }, - "on_behalf": true, - "requester": "mindel", - "requester_username": "Mindel", - "seasons": null, - "seerr": { - "outcome": "requested", - "request_id": 9 - }, - "source": "recommendation", - "state": "settled", - "tmdb_id": 42, - "updated_at": "2026-11-05T12:00:00Z" - }, - "hide": { - "created": true, - "dismissal": { - "hidden_at": "2026-11-05T12:00:00Z", - "hidden_by": "shared", - "media_type": "movie", - "poster_path": "/hidden.jpg", - "title": "Moon", - "tmdb_id": 42, - "year": 2017 - }, - "materialization": "current", - "profile": "shared" - }, - "restore": { - "media_type": "movie", - "profile": "shared", - "restored": true, - "tmdb_id": 42 - } -} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json deleted file mode 100644 index 4a3c7666..00000000 --- a/app/src/test/resources/embedded_subs.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "Protocol": "File", - "Id": "", - "Path": "", - "Type": "Default", - "Container": "mkv", - "Size": 651217902, - "Name": "", - "IsRemote": false, - "ETag": "", - "RunTimeTicks": 14919360000, - "ReadAtNativeFramerate": false, - "IgnoreDts": false, - "IgnoreIndex": false, - "GenPtsInput": false, - "SupportsTranscoding": true, - "SupportsDirectStream": true, - "SupportsDirectPlay": true, - "IsInfiniteStream": false, - "UseMostCompatibleTranscodingProfile": false, - "RequiresOpening": false, - "RequiresClosing": false, - "RequiresLooping": false, - "SupportsProbing": true, - "VideoType": "VideoFile", - "MediaStreams": [ - { - "Codec": "hevc", - "Language": "jpn", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "SDR", - "VideoRangeType": "SDR", - "AudioSpatialFormat": "None", - "DisplayTitle": "1080p - HEVC - SDR", - "IsInterlaced": false, - "IsAVC": false, - "BitRate": 3491934, - "BitDepth": 10, - "RefFrames": 1, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 1080, - "Width": 1448, - "AverageFrameRate": 23.809525, - "RealFrameRate": 23.809525, - "ReferenceFrameRate": 23.809525, - "Profile": "Main 10", - "Type": "Video", - "AspectRatio": "4:3", - "Index": 0, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "PixelFormat": "yuv420p10le", - "Level": 120, - "IsAnamorphic": false - }, - { - "Codec": "opus", - "Language": "jpn", - "TimeBase": "1/1000", - "Title": "JAP Stereo (Opus 112Kbps)", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "JAP Stereo (Opus 112Kbps) - Japanese", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "stereo", - "BitRate": 101618, - "Channels": 2, - "SampleRate": 48000, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Type": "Audio", - "Index": 1, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "aac", - "Language": "por", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "Portuguese - AAC - Stereo", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "stereo", - "BitRate": 249225, - "Channels": 2, - "SampleRate": 44100, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Profile": "LC", - "Type": "Audio", - "Index": 2, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "eac3", - "Language": "por", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "5.1", - "BitRate": 640000, - "Channels": 6, - "SampleRate": 48000, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Type": "Audio", - "Index": 3, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "ass", - "Language": "por", - "TimeBase": "1/1000", - "Title": "ptBR", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "ptBR - Portuguese - Default - ASS", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 4, - "IsExternal": false, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Level": 0, - "DeliveryMethod": "Embed" - }, - { - "Codec": "ass", - "Language": "por", - "TimeBase": "1/1000", - "Title": "ptBR FORCED", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "ptBR FORCED - Portuguese - ASS", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": true, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 5, - "IsExternal": false, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Level": 0, - "DeliveryMethod": "Embed" - }, - { - "Codec": "ass", - "Language": "eng", - "TimeBase": "1/1000", - "Title": "enUS", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "enUS - English - ASS", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 6, - "IsExternal": false, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Level": 0, - "DeliveryMethod": "Embed" - } - ], - "MediaAttachments": [], - "Formats": [], - "Bitrate": 4482777, - "RequiredHttpHeaders": {}, - "TranscodingSubProtocol": "http", - "DefaultAudioStreamIndex": 1, - "DefaultSubtitleStreamIndex": 6, - "HasSegments": true -} diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json deleted file mode 100644 index 1ef202d4..00000000 --- a/app/src/test/resources/external_subs.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "Protocol": "File", - "Id": "", - "Path": "", - "Type": "Default", - "Container": "mkv", - "Size": 2147179830, - "Name": "", - "IsRemote": false, - "ETag": "", - "RunTimeTicks": 12793200000, - "ReadAtNativeFramerate": false, - "IgnoreDts": false, - "IgnoreIndex": false, - "GenPtsInput": false, - "SupportsTranscoding": true, - "SupportsDirectStream": true, - "SupportsDirectPlay": true, - "IsInfiniteStream": false, - "UseMostCompatibleTranscodingProfile": false, - "RequiresOpening": false, - "RequiresClosing": false, - "RequiresLooping": false, - "SupportsProbing": true, - "VideoType": "VideoFile", - "MediaStreams": [ - { - "Codec": "subrip", - "Language": "eng", - "TimeBase": "1/1000", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "English - SUBRIP - External", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 0, - "IsExternal": true, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Path": "", - "Level": 0, - "DeliveryMethod": "External" - }, - { - "Codec": "subrip", - "Language": "eng", - "TimeBase": "1/1000", - "Title": "0", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "0 - English - SUBRIP - External", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 1, - "IsExternal": true, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Path": "", - "Level": 0, - "DeliveryMethod": "External" - }, - { - "Codec": "subrip", - "Language": "eng", - "TimeBase": "1/1000", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "English - SUBRIP - External", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 2, - "IsExternal": true, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Path": "", - "Level": 0, - "DeliveryMethod": "External" - }, - { - "Codec": "eac3", - "Language": "eng", - "TimeBase": "1/1000", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "5.1", - "BitRate": 640000, - "Channels": 6, - "SampleRate": 48000, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Type": "Audio", - "Index": 3, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "h264", - "ColorSpace": "bt709", - "ColorTransfer": "bt709", - "ColorPrimaries": "bt709", - "TimeBase": "1/1000", - "VideoRange": "SDR", - "VideoRangeType": "SDR", - "AudioSpatialFormat": "None", - "DisplayTitle": "1080p H264 SDR", - "NalLengthSize": "4", - "IsInterlaced": false, - "IsAVC": true, - "BitRate": 13427007, - "BitDepth": 8, - "RefFrames": 1, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 1080, - "Width": 1920, - "AverageFrameRate": 23.976025, - "RealFrameRate": 23.976025, - "ReferenceFrameRate": 23.976025, - "Profile": "High", - "Type": "Video", - "AspectRatio": "16:9", - "Index": 4, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "PixelFormat": "yuv420p", - "Level": 40, - "IsAnamorphic": false - }, - { - "Codec": "subrip", - "TimeBase": "1/1000", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "Undefined - Default - SUBRIP", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 5, - "IsExternal": false, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Level": 0, - "DeliveryMethod": "Embed" - } - ], - "MediaAttachments": [], - "Formats": [], - "Bitrate": 14067007, - "RequiredHttpHeaders": {}, - "TranscodingSubProtocol": "http", - "DefaultAudioStreamIndex": 3, - "DefaultSubtitleStreamIndex": 0, - "HasSegments": true -} diff --git a/app/src/test/resources/forgejo_release_latest.json b/app/src/test/resources/forgejo_release_latest.json deleted file mode 100644 index f99734b9..00000000 --- a/app/src/test/resources/forgejo_release_latest.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": 42, - "name": "v1.0.9-2-g3094fc8c", - "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", - "body": "Wolfskin build\n<!-- app-note:Subtitle preference fix -->\n**Full Changelog**: https://git.justinvisser.org/justinzeus/wolfskin/compare/v1.0.8...v1.0.9", - "draft": false, - "prerelease": false, - "created_at": "2026-10-20T09:00:00Z", - "published_at": "2026-10-20T09:05:00Z", - "target_commitish": "3094fc8c", - "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", - "assets": [ - { - "id": 101, - "name": "Wolfskin.apk", - "size": 12345678, - "download_count": 3, - "created_at": "2026-10-20T09:05:00Z", - "uuid": "11111111-1111-1111-1111-111111111111", - "type": "attachment", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin.apk" - }, - { - "id": 102, - "name": "Wolfskin-arm64-v8a.apk", - "size": 7654321, - "download_count": 1, - "created_at": "2026-10-20T09:05:00Z", - "uuid": "22222222-2222-2222-2222-222222222222", - "type": "attachment", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-arm64-v8a.apk" - }, - { - "id": 103, - "name": "Wolfskin-armeabi-v7a.apk", - "size": 6543210, - "download_count": 0, - "created_at": "2026-10-20T09:05:00Z", - "uuid": "33333333-3333-3333-3333-333333333333", - "type": "attachment", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-armeabi-v7a.apk" - } - ] -} diff --git a/app/src/test/resources/forgejo_release_suffixed.json b/app/src/test/resources/forgejo_release_suffixed.json deleted file mode 100644 index c2155081..00000000 --- a/app/src/test/resources/forgejo_release_suffixed.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "v1.0.9-2-g3094fc8c", - "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", - "published_at": "2026-10-20T09:05:00Z", - "body": "Wolfskin release and debug builds", - "assets": [ - { - "name": "Wolfskin-debug.apk", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug.apk" - }, - { - "name": "Wolfskin-release.apk", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release.apk" - }, - { - "name": "Wolfskin-debug-arm64-v8a.apk", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug-arm64-v8a.apk" - }, - { - "name": "Wolfskin-release-arm64-v8a.apk", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release-arm64-v8a.apk" - } - ] -} diff --git a/app/src/test/resources/forgejo_releases.json b/app/src/test/resources/forgejo_releases.json deleted file mode 100644 index ab84e411..00000000 --- a/app/src/test/resources/forgejo_releases.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "id": 44, - "name": "v1.0.10-0-gfeedbee", - "tag_name": "pwolfskin-v1.0.10-0-gfeedbee", - "body": "", - "draft": false, - "prerelease": false, - "created_at": "2026-10-22T08:00:00Z", - "published_at": "2026-10-22T08:05:00Z", - "target_commitish": "feedbee", - "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.10-0-gfeedbee", - "assets": [ - { - "id": 104, - "name": "Wolfskin.apk", - "size": 12345679, - "download_count": 0, - "created_at": "2026-10-22T08:05:00Z", - "uuid": "44444444-4444-4444-4444-444444444444", - "type": "attachment", - "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.10-0-gfeedbee/Wolfskin.apk" - } - ] - }, - { - "id": 42, - "name": "v1.0.9-2-g3094fc8c", - "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", - "body": "Wolfskin build", - "draft": false, - "prerelease": false, - "created_at": "2026-10-20T09:00:00Z", - "published_at": "2026-10-20T09:05:00Z", - "target_commitish": "3094fc8c", - "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", - "assets": [] - }, - { - "id": 40, - "name": "nightly", - "tag_name": "nightly", - "body": "", - "draft": false, - "prerelease": true, - "created_at": "2026-10-10T09:00:00Z", - "published_at": "2026-10-10T09:05:00Z", - "target_commitish": "main", - "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/nightly", - "assets": [] - } -] diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json deleted file mode 100644 index d746913e..00000000 --- a/app/src/test/resources/no_embedded_subs.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "Protocol": "File", - "Id": "", - "Path": "", - "Type": "Default", - "Container": "mkv", - "Size": 651217902, - "Name": "", - "IsRemote": false, - "ETag": "", - "RunTimeTicks": 14919360000, - "ReadAtNativeFramerate": false, - "IgnoreDts": false, - "IgnoreIndex": false, - "GenPtsInput": false, - "SupportsTranscoding": true, - "SupportsDirectStream": true, - "SupportsDirectPlay": true, - "IsInfiniteStream": false, - "UseMostCompatibleTranscodingProfile": false, - "RequiresOpening": false, - "RequiresClosing": false, - "RequiresLooping": false, - "SupportsProbing": true, - "VideoType": "VideoFile", - "MediaStreams": [ - { - "Codec": "subrip", - "Language": "spa", - "TimeBase": "1/1000", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedUndefined": "Undefined", - "LocalizedDefault": "Default", - "LocalizedForced": "Forced", - "LocalizedExternal": "External", - "LocalizedHearingImpaired": "Hearing Impaired", - "DisplayTitle": "Spanish - SUBRIP - External", - "IsInterlaced": false, - "IsAVC": false, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 0, - "Width": 0, - "Type": "Subtitle", - "Index": 0, - "IsExternal": true, - "IsTextSubtitleStream": true, - "SupportsExternalStream": true, - "Path": "", - "Level": 0, - "DeliveryMethod": "External" - }, - { - "Codec": "hevc", - "Language": "jpn", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "SDR", - "VideoRangeType": "SDR", - "AudioSpatialFormat": "None", - "DisplayTitle": "1080p - HEVC - SDR", - "IsInterlaced": false, - "IsAVC": false, - "BitRate": 3491934, - "BitDepth": 10, - "RefFrames": 1, - "IsDefault": true, - "IsForced": false, - "IsHearingImpaired": false, - "Height": 1080, - "Width": 1448, - "AverageFrameRate": 23.809525, - "RealFrameRate": 23.809525, - "ReferenceFrameRate": 23.809525, - "Profile": "Main 10", - "Type": "Video", - "AspectRatio": "4:3", - "Index": 1, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "PixelFormat": "yuv420p10le", - "Level": 120, - "IsAnamorphic": false - }, - { - "Codec": "opus", - "Language": "jpn", - "TimeBase": "1/1000", - "Title": "Stereo (Opus 112Kbps)", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "Stereo (Opus 112Kbps) - Japanese", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "stereo", - "BitRate": 101618, - "Channels": 2, - "SampleRate": 48000, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Type": "Audio", - "Index": 2, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "aac", - "Language": "por", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "Portuguese - AAC - Stereo", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "stereo", - "BitRate": 249225, - "Channels": 2, - "SampleRate": 44100, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Profile": "LC", - "Type": "Audio", - "Index": 3, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - }, - { - "Codec": "eac3", - "Language": "por", - "TimeBase": "1/1000", - "Title": "", - "VideoRange": "Unknown", - "VideoRangeType": "Unknown", - "AudioSpatialFormat": "None", - "LocalizedDefault": "Default", - "LocalizedExternal": "External", - "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", - "IsInterlaced": false, - "IsAVC": false, - "ChannelLayout": "5.1", - "BitRate": 640000, - "Channels": 6, - "SampleRate": 48000, - "IsDefault": false, - "IsForced": false, - "IsHearingImpaired": false, - "Type": "Audio", - "Index": 4, - "IsExternal": false, - "IsTextSubtitleStream": false, - "SupportsExternalStream": false, - "Level": 0 - } - ], - "MediaAttachments": [], - "Formats": [], - "Bitrate": 4482777, - "RequiredHttpHeaders": {}, - "TranscodingSubProtocol": "http", - "DefaultAudioStreamIndex": 2, - "HasSegments": true -} diff --git a/app/src/test/resources/release_develop.json b/app/src/test/resources/release_develop.json deleted file mode 100644 index df48106f..00000000 --- a/app/src/test/resources/release_develop.json +++ /dev/null @@ -1,475 +0,0 @@ -{ - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448", - "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448/assets", - "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/297064448/assets{?name,label}", - "html_url": "https://github.com/damontecres/Wholphin/releases/tag/develop", - "id": 297064448, - "author": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "node_id": "RE_kwDOPzwRps4RtNgA", - "tag_name": "develop", - "target_commitish": "main", - "name": "v0.5.3-8-g627b89f1", - "draft": false, - "immutable": false, - "prerelease": true, - "created_at": "2026-03-14T20:47:08Z", - "updated_at": "2026-03-14T20:54:31Z", - "published_at": "2026-03-14T20:54:31Z", - "assets": [ - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937404", - "id": 373937404, - "node_id": "RA_kwDOPzwRps4WSdT8", - "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 65936692, - "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", - "download_count": 0, - "created_at": "2026-03-14T20:54:23Z", - "updated_at": "2026-03-14T20:54:26Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937407", - "id": 373937407, - "node_id": "RA_kwDOPzwRps4WSdT_", - "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 62726455, - "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", - "download_count": 0, - "created_at": "2026-03-14T20:54:24Z", - "updated_at": "2026-03-14T20:54:25Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937406", - "id": 373937406, - "node_id": "RA_kwDOPzwRps4WSdT-", - "name": "Wholphin-debug-0.5.3-8-g627b89f1-44.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 99552740, - "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", - "download_count": 0, - "created_at": "2026-03-14T20:54:24Z", - "updated_at": "2026-03-14T20:54:26Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937405", - "id": 373937405, - "node_id": "RA_kwDOPzwRps4WSdT9", - "name": "Wholphin-debug-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 65936692, - "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", - "download_count": 0, - "created_at": "2026-03-14T20:54:23Z", - "updated_at": "2026-03-14T20:54:26Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937408", - "id": 373937408, - "node_id": "RA_kwDOPzwRps4WSdUA", - "name": "Wholphin-debug-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 62726455, - "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", - "download_count": 0, - "created_at": "2026-03-14T20:54:24Z", - "updated_at": "2026-03-14T20:54:26Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937422", - "id": 373937422, - "node_id": "RA_kwDOPzwRps4WSdUO", - "name": "Wholphin-debug.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 99552740, - "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", - "download_count": 0, - "created_at": "2026-03-14T20:54:26Z", - "updated_at": "2026-03-14T20:54:29Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937424", - "id": 373937424, - "node_id": "RA_kwDOPzwRps4WSdUQ", - "name": "Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 56651710, - "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", - "download_count": 0, - "created_at": "2026-03-14T20:54:26Z", - "updated_at": "2026-03-14T20:54:28Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937427", - "id": 373937427, - "node_id": "RA_kwDOPzwRps4WSdUT", - "name": "Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 53441542, - "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", - "download_count": 0, - "created_at": "2026-03-14T20:54:26Z", - "updated_at": "2026-03-14T20:54:28Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937431", - "id": 373937431, - "node_id": "RA_kwDOPzwRps4WSdUX", - "name": "Wholphin-release-0.5.3-8-g627b89f1-44.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 90267831, - "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", - "download_count": 0, - "created_at": "2026-03-14T20:54:27Z", - "updated_at": "2026-03-14T20:54:29Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937439", - "id": 373937439, - "node_id": "RA_kwDOPzwRps4WSdUf", - "name": "Wholphin-release-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 56651710, - "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", - "download_count": 1, - "created_at": "2026-03-14T20:54:27Z", - "updated_at": "2026-03-14T20:54:29Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937452", - "id": 373937452, - "node_id": "RA_kwDOPzwRps4WSdUs", - "name": "Wholphin-release-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 53441542, - "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", - "download_count": 0, - "created_at": "2026-03-14T20:54:28Z", - "updated_at": "2026-03-14T20:54:30Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937453", - "id": 373937453, - "node_id": "RA_kwDOPzwRps4WSdUt", - "name": "Wholphin-release.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 90267831, - "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", - "download_count": 1, - "created_at": "2026-03-14T20:54:28Z", - "updated_at": "2026-03-14T20:54:31Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk" - } - ], - "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/develop", - "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/develop", - "body": "### `main` development build\n\nThis pre-release tracks the development build of Wholphin from the `main` unstable branch.\n\nSee https://github.com/damontecres/Wholphin/releases/latest for the latest stable release.\n\nIf you want to update to this version in-app, change Settings->Advanced->Update URL to https://api.github.com/repos/damontecres/Wholphin/releases/tags/develop (replace `latest` with `tags/develop`).\n\n[See changes since `v0.5.3`](https://github.com/damontecres/Wholphin/compare/v0.5.3...develop)\n" -} diff --git a/app/src/test/resources/release_v108.json b/app/src/test/resources/release_v108.json deleted file mode 100644 index 1a64aad8..00000000 --- a/app/src/test/resources/release_v108.json +++ /dev/null @@ -1,344 +0,0 @@ -{ - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926", - "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926/assets", - "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/388459926/assets{?name,label}", - "html_url": "https://github.com/damontecres/Wholphin/releases/tag/v1.0.8", - "id": 388459926, - "author": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "node_id": "RE_kwDOPzwRps4XJ22W", - "tag_name": "v1.0.8", - "target_commitish": "main", - "name": "v1.0.8", - "draft": false, - "immutable": false, - "prerelease": false, - "created_at": "2026-09-14T14:04:10Z", - "updated_at": "2026-09-14T14:28:03Z", - "published_at": "2026-09-14T14:28:03Z", - "assets": [ - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552435", - "id": 563552435, - "node_id": "RA_kwDOPzwRps4hlyCz", - "name": "Wholphin-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 46041302, - "digest": "sha256:77504857a15050beed9a8e589b70c29773b5e6a3c6ae76deec79c10702129755", - "download_count": 485, - "created_at": "2026-09-14T14:23:34Z", - "updated_at": "2026-09-14T14:23:39Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552436", - "id": 563552436, - "node_id": "RA_kwDOPzwRps4hlyC0", - "name": "Wholphin-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 41771730, - "digest": "sha256:7a7a031104f42a8314e3deed4febb670b515687ac500026f59a997dae772d950", - "download_count": 502, - "created_at": "2026-09-14T14:23:34Z", - "updated_at": "2026-09-14T14:23:39Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552433", - "id": 563552433, - "node_id": "RA_kwDOPzwRps4hlyCx", - "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-arm64-v8a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 46041302, - "digest": "sha256:77504857a15050beed9a8e589b70c29773b5e6a3c6ae76deec79c10702129755", - "download_count": 170, - "created_at": "2026-09-14T14:23:34Z", - "updated_at": "2026-09-14T14:23:39Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-arm64-v8a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552428", - "id": 563552428, - "node_id": "RA_kwDOPzwRps4hlyCs", - "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-armeabi-v7a.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 41771730, - "digest": "sha256:7a7a031104f42a8314e3deed4febb670b515687ac500026f59a997dae772d950", - "download_count": 143, - "created_at": "2026-09-14T14:23:34Z", - "updated_at": "2026-09-14T14:23:39Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-armeabi-v7a.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552432", - "id": 563552432, - "node_id": "RA_kwDOPzwRps4hlyCw", - "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59-x86_64.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 53184555, - "digest": "sha256:74ba578c1059968c10c0884cc290d74a84793d9b78713beb0805baef7fb0a53f", - "download_count": 29, - "created_at": "2026-09-14T14:23:34Z", - "updated_at": "2026-09-14T14:23:42Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59-x86_64.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552634", - "id": 563552634, - "node_id": "RA_kwDOPzwRps4hlyF6", - "name": "Wholphin-default-release-1.0.8-0-g7af7bc0a-59.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 124985861, - "digest": "sha256:9e569dcc4207a7817b777ffa1885cc42d22a85b5a96824a56b8ddc0f34cccd69", - "download_count": 78, - "created_at": "2026-09-14T14:23:39Z", - "updated_at": "2026-09-14T14:23:49Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-default-release-1.0.8-0-g7af7bc0a-59.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552644", - "id": 563552644, - "node_id": "RA_kwDOPzwRps4hlyGE", - "name": "Wholphin-x86_64.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 53184555, - "digest": "sha256:74ba578c1059968c10c0884cc290d74a84793d9b78713beb0805baef7fb0a53f", - "download_count": 58, - "created_at": "2026-09-14T14:23:39Z", - "updated_at": "2026-09-14T14:23:44Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-x86_64.apk" - }, - { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/563552645", - "id": 563552645, - "node_id": "RA_kwDOPzwRps4hlyGF", - "name": "Wholphin.apk", - "label": "", - "uploader": { - "login": "github-actions[bot]", - "id": 41898282, - "node_id": "MDM6Qm90NDE4OTgyODI=", - "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", - "gravatar_id": "", - "url": "https://api.github.com/users/github-actions%5Bbot%5D", - "html_url": "https://github.com/apps/github-actions", - "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", - "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", - "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", - "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", - "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", - "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", - "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", - "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", - "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", - "type": "Bot", - "user_view_type": "public", - "site_admin": false - }, - "content_type": "application/vnd.android.package-archive", - "state": "uploaded", - "size": 124985861, - "digest": "sha256:9e569dcc4207a7817b777ffa1885cc42d22a85b5a96824a56b8ddc0f34cccd69", - "download_count": 3215, - "created_at": "2026-09-14T14:23:39Z", - "updated_at": "2026-09-14T14:23:47Z", - "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin.apk" - } - ], - "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/v1.0.8", - "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/v1.0.8", - "body": "<!-- Release notes generated using configuration in .github/release.yml at v1.0.8 -->\r\n\r\n## What's Changed\r\n\r\nThis release adds more media types to the favorites and search pages! There are also many bug fixes.\r\n\r\n### New Features & Enhancements\r\n* Add more searchable types & choose what to search in https://github.com/damontecres/Wholphin/pull/1857\r\n* Update favorites page with more types in https://github.com/damontecres/Wholphin/pull/1864\r\n* Add controls to edit playlists in https://github.com/damontecres/Wholphin/pull/1927\r\n### UI/UX Improvements\r\n* Add button to navigate to random item in https://github.com/damontecres/Wholphin/pull/1916\r\n* Add wrap around scrolling to settings pages in https://github.com/damontecres/Wholphin/pull/1937\r\n### Bug Fixes\r\n* Fix white flash on app start in https://github.com/damontecres/Wholphin/pull/1909\r\n* Fix image loading issues related to VPN and/or Fire OS 6 in https://github.com/damontecres/Wholphin/pull/1910\r\n* Fixes to default home view options for music & videos in https://github.com/damontecres/Wholphin/pull/1926\r\n* Fixes to Now Playing page in https://github.com/damontecres/Wholphin/pull/1915\r\n* Fixes to season/episode search with duplicates in https://github.com/damontecres/Wholphin/pull/1946\r\n* Configure playback controls for RTL in https://github.com/damontecres/Wholphin/pull/1945\r\n* Fix person credits: movie release dates, full filmography, sorted order by @SergeiAK in https://github.com/damontecres/Wholphin/pull/1950\r\n* Fixes to `v1.0.7` in https://github.com/damontecres/Wholphin/pull/1953\r\n* Fix OS screensaver starting during playback with dim enabled in https://github.com/damontecres/Wholphin/pull/1956\r\n* Fix favorites not querying for people & fix restoring focus on search page for discover items in https://github.com/damontecres/Wholphin/pull/1961\r\n### Other changes\r\n* Update API level to Android 17 in https://github.com/damontecres/Wholphin/pull/1908\r\n* Localized formatting for season and episode display by @tal-sarid in https://github.com/damontecres/Wholphin/pull/1884\r\n* Consolidate sending media reports & app logs in https://github.com/damontecres/Wholphin/pull/1936\r\n* Update dependencies in https://github.com/damontecres/Wholphin/pull/1952\r\n* Increase memory for CI builds in https://github.com/damontecres/Wholphin/pull/1954\r\n* Translations 2026-09-12 in https://github.com/damontecres/Wholphin/pull/1957\r\n\r\n## New Contributors\r\n* @SergeiAK made their first contribution in https://github.com/damontecres/Wholphin/pull/1950\r\n\r\n**Full Changelog**: https://github.com/damontecres/Wholphin/compare/v1.0.7...v1.0.8", - "reactions": { - "url": "https://api.github.com/repos/damontecres/Wholphin/releases/388459926/reactions", - "total_count": 34, - "+1": 10, - "-1": 0, - "laugh": 0, - "hooray": 6, - "confused": 0, - "heart": 10, - "rocket": 8, - "eyes": 0 - }, - "mentions_count": 2 -} diff --git a/app/src/test/resources/title-press-paths.json b/app/src/test/resources/title-press-paths.json deleted file mode 100644 index 14b28147..00000000 --- a/app/src/test/resources/title-press-paths.json +++ /dev/null @@ -1,92 +0,0 @@ -[ - { - "goal": "Round 3 · title page → nav", - "keys": [ - "OK", - "RIGHT", - "OK", - "UP" - ], - "presses": 4, - "note": "1 from the title page", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", - "entryPresses": 3, - "title": "The Girl Who Leapt Through Time", - "end": "nav" - }, - { - "goal": "Round 3 · play Locke with version 2 (sample files)", - "keys": [ - "OK", - "DOWN", - "DOWN", - "DOWN", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "OK", - "RIGHT", - "OK", - "DOWN", - "OK", - "LEFT", - "OK" - ], - "presses": 15, - "note": "6 on the title page", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", - "entryPresses": 9, - "title": "Locke", - "end": "play-version-2" - }, - { - "goal": "Round 3 · Locke → its first cast member’s page", - "keys": [ - "OK", - "DOWN", - "DOWN", - "DOWN", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "OK", - "DOWN", - "OK" - ], - "presses": 11, - "note": "2 on the title page: ↓ to Cast, OK", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", - "entryPresses": 9, - "title": "Locke", - "end": "person-Tom Hardy" - }, - { - "goal": "Round 3 · switch person from a title page", - "keys": [ - "OK", - "RIGHT", - "OK", - "UP", - "LEFT", - "OK", - "RIGHT", - "OK" - ], - "presses": 8, - "note": "5 from the title page", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", - "entryPresses": 3, - "title": "The Girl Who Leapt Through Time", - "end": "person-mindel" - } -] diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt deleted file mode 100644 index 00420a95..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.LibraryAudience -import org.justinvisser.wolfskin.conductarr.model.Options -import org.justinvisser.wolfskin.conductarr.model.Page -import org.justinvisser.wolfskin.design.* - -/** One lane can interleave movie and TV reads without exposing transport pages on screen. */ -internal data class CatalogueLaneQuery( - val id: String, - val name: String, - val media: List<MediaType>, - val sort: DiscoverSort = DiscoverSort.POPULAR, - val service: Long? = null, - val genres: Map<MediaType, Long> = emptyMap(), -) - -/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ -internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) { - private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) - private val sources = mutableMapOf<String, SourcePage>() - private val visited = mutableSetOf<String>() - private val mutex = Mutex() - - suspend fun saved(): List<DiscoverLane> = mutex.withLock { - queries.forEach { load(it, ReadTrigger.CACHE_ONLY, false) } - lanes() - } - - suspend fun through(index: Int, force: Boolean = false): List<DiscoverLane> = mutex.withLock { - queries.take((index + 1).coerceIn(0, queries.size)).forEach { query -> - if (force || query.id !in visited) { - load(query, if (force) ReadTrigger.FORCE else ReadTrigger.IF_STALE, false) - visited.add(query.id) - } - } - lanes() - } - - suspend fun more(id: String): List<DiscoverLane> = mutex.withLock { - queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) } - lanes() - } - - private suspend fun load(query: CatalogueLaneQuery, trigger: ReadTrigger, next: Boolean) = coroutineScope { - val updates = query.media.map { media -> async { - val key = "${query.id}:${media.name}" - val previous = sources[key] - if (next && previous?.last == true && !previous.failed) return@async key to previous - val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1 - val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort) - val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull() - val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } - if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null - val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) } - val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items - key to SourcePage(if (saved != null) page else previous?.number ?: 1, - saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(), - result is ReadResult.Failure || saved?.state == Page.State.PARTIAL, - saved?.totalResults?.toInt() ?: previous?.total ?: 0, - next && saved != null && titles.size == previous?.titles?.size && saved.page < saved.totalPages) - } }.map { it.await() } - updates.forEach { (key, value) -> if (value != null) sources[key] = value } - } - - private fun lanes(): List<DiscoverLane> = queries.map { query -> - val pages = query.media.mapNotNull { sources["${query.id}:${it.name}"] } - val items = pages.flatMap { it.titles }.distinctBy { it.key } - val pending = pages.isEmpty() && query.id !in visited - DiscoverLane(query.id, query.name, items, total = pages.sumOf { it.total }, tail = when { - pages.any { it.failed } -> DiscoverTail.Retry - pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore - pages.any { !it.last } -> DiscoverTail.SeeAll - else -> DiscoverTail.None - }, pending = pending, hasMore = pages.any { !it.last }) - } -} - -/** Option ids, not guessed service ids, determine every catalogue parameter. */ -internal fun catalogueQueries(options: List<Options>, service: DiscoverService? = null, type: String? = null): List<CatalogueLaneQuery> { - val media = if (type == null) MediaType.entries else listOf(if (type == "movie") MediaType.MOVIE else MediaType.TV) - val noun = if (type == "movie") "movies" else "series" - val genres = options.flatMap { option -> option.genres.orEmpty().map { it.name to (MediaType.valueOf(option.mediaType.name) to it.id) } } - .groupBy({ it.first }, { it.second }) - if (service != null) return listOf( - CatalogueLaneQuery("sv-pop", "Popular $noun", media, service = service.id), - CatalogueLaneQuery("sv-new", "New $noun", media, DiscoverSort.NEW, service.id), - CatalogueLaneQuery("sv-top", "Top rated $noun", media, DiscoverSort.TOP, service.id), - ) + genres.entries.filter { entry -> entry.value.any { it.first in media } }.take(3).map { (name, ids) -> - CatalogueLaneQuery("sv-g-$name", "$name $noun", media.filter { item -> ids.any { it.first == item } }, service = service.id, genres = ids.toMap()) - } - val services = options.flatMap { it.services.orEmpty() }.distinctBy { it.id } - return services.map { CatalogueLaneQuery("new-${it.id}", "New on ${it.name}", media, DiscoverSort.NEW, it.id) } + - CatalogueLaneQuery("trending", "Trending", media) + - listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").mapNotNull { name -> - genres[name]?.let { ids -> CatalogueLaneQuery("genre-$name", name, - ids.map { it.first }.distinct(), genres = ids.toMap()) } - } + CatalogueLaneQuery("soon", "Coming soon", media, DiscoverSort.UPCOMING) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt deleted file mode 100644 index c558e893..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrDiscoverOperations.kt +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.sync.withLock -import org.justinvisser.wolfskin.conductarr.ClientError -import org.justinvisser.wolfskin.conductarr.ConductarrClient -import org.justinvisser.wolfskin.conductarr.MediaType -import org.justinvisser.wolfskin.conductarr.MutationResult -import org.justinvisser.wolfskin.conductarr.ReadResult -import org.justinvisser.wolfskin.conductarr.TvRoutes -import org.justinvisser.wolfskin.conductarr.model.RecommendationHideInputBody -import org.justinvisser.wolfskin.conductarr.model.RequestIntentBody -import org.justinvisser.wolfskin.conductarr.model.RequestReceipt -import org.justinvisser.wolfskin.design.DiscoverOperations -import org.justinvisser.wolfskin.design.DiscoverRequest -import org.justinvisser.wolfskin.design.DiscoverRequestResult -import org.justinvisser.wolfskin.design.DiscoverSnapshot -import org.justinvisser.wolfskin.design.DiscoverTitle -import org.justinvisser.wolfskin.design.RequestPhase - -/** An injected signed-in client is the only real mutation path; the fixture host never constructs one. */ -class ConductarrDiscoverOperations( - private val client: ConductarrClient, - private val refreshSnapshot: suspend () -> DiscoverSnapshot, - private val profile: String? = null, - private val allowedRequesters: Set<String>? = null, - private val canRequest: Boolean = true, -) : DiscoverOperations { - private val submissions = mutableSetOf<Pair<String, String>>() - private val requestMutex = kotlinx.coroutines.sync.Mutex() - private val dismissalMutex = kotlinx.coroutines.sync.Mutex() - override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { - requestMutex.lock() - try { - val title = intent.title - if (!canRequest || allowedRequesters?.contains(intent.requester) == false || - (title.type == "tv" && title.seasons.isNullOrEmpty())) return DiscoverRequestResult(RequestPhase.Refused) - val key = title.key to intent.requester - val lookup = client.read(TvRoutes.requests(title.tmdb, title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last() - if (lookup is ReadResult.Failure) return DiscoverRequestResult(RequestPhase.NotConfirmed) - val existing = (lookup as ReadResult.Value).saved.value?.requests?.firstOrNull { - it.requester == intent.requester && (title.type != "tv" || it.seasons.orEmpty().containsAll(title.seasons.orEmpty().map(Int::toLong))) - } - if (existing != null) return existing.outcome() - if (!submissions.add(key)) return DiscoverRequestResult(RequestPhase.NotConfirmed) - val body = RequestIntentBody( - mediaType = if (title.type == "movie") RequestIntentBody.MediaType.MOVIE else RequestIntentBody.MediaType.TV, - recommendationProfile = (profile ?: intent.profile).takeIf { intent.fromRecommendation }, requester = intent.requester, - seasons = title.seasons?.map(Int::toLong), - source = if (intent.fromRecommendation) RequestIntentBody.Source.RECOMMENDATION else RequestIntentBody.Source.TITLE, - tmdbId = title.tmdb, - ) - return when (val result = client.mutate(TvRoutes.request(body))) { - is MutationResult.Success -> result.value.outcome() - is MutationResult.Failure -> DiscoverRequestResult(if (result.error == ClientError.Http(403)) RequestPhase.Refused else RequestPhase.NotConfirmed) - } - } finally { requestMutex.unlock() } - } - - override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { - return when (val result = client.read(TvRoutes.requests(intent.title.tmdb, intent.title.mediaType()), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { - is ReadResult.Value -> result.saved.value?.requests?.firstOrNull { - it.requester == intent.requester && (intent.title.type != "tv" || it.seasons.orEmpty().containsAll(intent.title.seasons.orEmpty().map(Int::toLong))) - }?.outcome() ?: DiscoverRequestResult(RequestPhase.NotConfirmed) - is ReadResult.Failure -> DiscoverRequestResult(RequestPhase.NotConfirmed) - } - } - - override suspend fun hide(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { - val result = client.mutate(TvRoutes.hide(this.profile ?: profile, - RecommendationHideInputBody(if (title.type == "movie") RecommendationHideInputBody.MediaType.MOVIE else RecommendationHideInputBody.MediaType.TV, - title = title.title, tmdbId = title.tmdb, year = title.year?.toLong()))) - result is MutationResult.Success || observedHidden(profile, title) == true - } - - override suspend fun restore(profile: String, title: DiscoverTitle): Boolean = dismissalMutex.withLock { - val result = client.mutate(TvRoutes.restore(this.profile ?: profile, title.tmdb, title.mediaType())) - result is MutationResult.Success || observedHidden(profile, title) == false - } - - private suspend fun observedHidden(profile: String, title: DiscoverTitle): Boolean? { - return when (val result = client.read(TvRoutes.dismissals(this.profile ?: profile), org.justinvisser.wolfskin.conductarr.ReadTrigger.FORCE).last()) { - is ReadResult.Value -> result.saved.value?.dismissals?.any { - it.tmdbId == title.tmdb && it.mediaType.name == title.mediaType().name - } - is ReadResult.Failure -> null - } - } - override suspend fun refresh(): DiscoverSnapshot = refreshSnapshot() -} - -private fun DiscoverTitle.mediaType(): MediaType = if (type == "movie") MediaType.MOVIE else MediaType.TV -private fun RequestReceipt.outcome(): DiscoverRequestResult = DiscoverRequestResult( - phase = if (state == RequestReceipt.State.PENDING) RequestPhase.Pending else if (state == RequestReceipt.State.RETIRED) RequestPhase.Refused else RequestPhase.Receipt, - actor = actor.name, - requester = requester, -) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt deleted file mode 100644 index 559b4365..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrPreference.kt +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import android.content.SharedPreferences -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import java.io.File -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import org.justinvisser.wolfskin.conductarr.CacheEntry -import org.justinvisser.wolfskin.conductarr.CacheStore -import org.justinvisser.wolfskin.conductarr.FileCacheStore - -/** Advanced-only endpoint and fixture controls contain no credentials. */ -object ConductarrPreference { - private var preferences: SharedPreferences? = null - var baseUrl by mutableStateOf(BuildConfig.CONDUCTARR_BASE_URL) - private set - var fixtures by mutableStateOf(false) - private set - - fun initialize(context: Context) { - preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - baseUrl = preferences?.getString("wolfskin.conductarr.url", null)?.takeIf { it.isNotBlank() } ?: BuildConfig.CONDUCTARR_BASE_URL - fixtures = BuildConfig.DEBUG && preferences?.getBoolean("wolfskin.fixtures", false) == true - } - - fun updateBaseUrl(value: String) { - val normalized = value.trim().ifBlank { BuildConfig.CONDUCTARR_BASE_URL } - baseUrl = normalized - preferences?.edit { putString("wolfskin.conductarr.url", normalized) } - } - - fun updateFixtures(value: Boolean) { - fixtures = BuildConfig.DEBUG && value - preferences?.edit { putBoolean("wolfskin.fixtures", fixtures) } - } -} - -/** Household snapshots must not enter Android backup or shared storage. */ -@Module -@InstallIn(SingletonComponent::class) -object ConductarrModule { - @Provides - @Singleton - fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession = - ConductarrSession(ConductarrDiskCache(context), scope) -} - -/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */ -private class ConductarrDiskCache(context: Context) : CacheStore { - private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) } - override fun read(scope: String, key: String) = store.read(scope, key) - override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity) - override fun remove(scope: String, key: String) = store.remove(scope, key) - override fun clear(scope: String) = store.clear(scope) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt deleted file mode 100644 index 1718dc44..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* - -/** Relative TMDB art paths are not app or Conductarr-relative URLs. */ -internal fun catalogueImage(path: String?): String? = path?.takeIf { it.isNotBlank() }?.let { - if (it.startsWith("/")) "https://image.tmdb.org/t/p/w780$it" else it -} - -internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle = DiscoverTitle( - tmdbId, mediaType.name.lowercase(), title, year.toInt(), genreIds.orEmpty().mapNotNull(genres::get), overview, - catalogueImage(posterPath), catalogueImage(backdropPath), score = score, -) - -internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle( - tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, - poster = posterUrl, backdrop = backdropUrl, - heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() }, -) - -internal fun DiscoverSnapshot.homeRows(): List<HomeRow> { - if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) - if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true)) - if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable)) - val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) -> - picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items -> - HomeRow("pk-$type", HomeRowKind.Shelf, label, items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) - } - } - return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> - if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true) - else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title, - items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) } - } + HomeRow("explore", HomeRowKind.Explore) -} - -internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle( - id = key, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds, seasons = seasonCount, - overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(), -) - -internal fun RequestProgress.comingIn(): ComingInRequest { - val stagesByName = stages.orEmpty().associateBy { it.name } - val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION] - val visibility = stagesByName[ReadinessStage.Name.VISIBILITY] - val stage = when { - stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed - visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready - awaitingApproval == true -> ComingInStage.Waiting - visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady - acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading - acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching - stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking - else -> ComingInStage.Waiting - } - return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, - requester.name, actor?.requester, actor?.name, stage) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt deleted file mode 100644 index 06caea9c..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.conductarr.MediaType - -/** Screen projections share the identity-scoped transport, never a second HTTP or credential path. */ -class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider by ClientTitleProvider(client) { - private var catalogue: ConductarrCatalogue? = null - private var options = emptyList<Options>() - private val services = mutableMapOf<String, ConductarrCatalogue>() - @Volatile private var libraryAudiences = emptyList<LibraryAudience>() - private var requestOperations: ConductarrDiscoverOperations? = null - private val profilesMutex = Mutex() - private var observedProfiles: RecommendationProfiles? = null - private val profileNames = MutableStateFlow<List<ReadProfile>>(emptyList()) - val requesterProfiles = profileNames.asStateFlow() - - /** One uncertain-submission ledger survives screen/navigation changes for this identity. */ - fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) { - requestOperations ?: ConductarrDiscoverOperations(client, { - profiles() - discover(operator.recommendationProfile, force = true).lastOrNull() - ?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) - }, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia) - .also { requestOperations = it } - } - fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow { - val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) - if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved) - emit(discoverSnapshot(profile, if (force) ReadTrigger.FORCE else ReadTrigger.OPEN)) - } - - private suspend fun discoverSnapshot(profile: String, trigger: ReadTrigger): DiscoverSnapshot = coroutineScope { - val reads = SnapshotReads(client, trigger) - val feedJob = async { reads.read(TvRoutes.feed(profile)) } - val hiddenJob = async { reads.read(TvRoutes.dismissals(profile)) } - val optionsJobs = MediaType.entries.map { media -> async { reads.read(TvRoutes.options(media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.FOREGROUND) } } - val feed = feedJob.await() - val options = optionsJobs.mapNotNull { it.await() } - this@ConductarrProvider.options = options - val queries = catalogueQueries(options) - val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it } - val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) - val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } - val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } - val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> - candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } - }.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } - val hidden = hiddenJob.await()?.dismissals.orEmpty().map { - DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath)) - } - val status = when { - reads.needsSignIn -> DiscoverStatus.NeedsSignIn - feed == null && lanes.all { it.titles.isEmpty() } -> DiscoverStatus.Unavailable - feed?.outcome == RecommendationFeed.Outcome.NO_SEEDS -> DiscoverStatus.NoSeeds - feed == null || feed.state == RecommendationFeed.State.UNAVAILABLE -> DiscoverStatus.PicksUnavailable - else -> DiscoverStatus.Ready - } - DiscoverSnapshot(picks, seeds, options.flatMap { it.services.orEmpty() }.distinctBy { it.id }.map { DiscoverService(it.id, it.name, discoverServiceHue(it.name)) }, - lanes, hidden, status, reads.savedTime(), reachable = !reads.failed) - } - - /** Read two shelves ahead; pagination is keyed by the exact service, genre and media parameters. */ - suspend fun lanesThrough(index: Int): List<DiscoverLane> = catalogue?.through(index).orEmpty() - suspend fun more(lane: String): List<DiscoverLane> = catalogue?.more(lane).orEmpty() - suspend fun readAhead(lane: String?): List<DiscoverLane> { - val loader = catalogue ?: return emptyList() - val index = loader.queries.indexOfFirst { it.id == lane } - return loader.through(index + 2) - } - - suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> { - val key = "${service.id}:$type" - val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } } - return if (more != null) loader.more(more) else loader.through(through) - } - - fun requests(): Flow<ComingInSnapshot> = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.FOREGROUND)) { - val reads = SnapshotReads(client, trigger) - val feed = reads.read(TvRoutes.activity()) - if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue - val artReads = SnapshotReads(client, trigger) - val items = coroutineScope { - feed?.items.orEmpty().map { item -> async { - val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV - val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) - item.comingIn().copy(poster = art?.posterUrl) - } }.map { it.await() } - } - emit(ComingInSnapshot(items, - if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready, - partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null, - savedAt = reads.savedTime())) - } - } - - suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock { - observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also { - observedProfiles = it - profileNames.value = it.profiles.orEmpty() - } - } - /** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */ - suspend fun audiences(): List<LibraryAudience>? { - val reads = SnapshotReads(client, ReadTrigger.IF_STALE) - val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() } - if (reads.failed || observed == null) return null - libraryAudiences = observed - return observed - } - - fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy -} - -/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */ -internal fun ConductarrIdentity.requesters(profiles: List<ReadProfile>, saved: List<ShellPerson>): List<ShellPerson> { - val allowed = operator?.capabilities?.requesters.orEmpty() - return buildMap { - saved.filter { it.key in allowed }.forEach { put(it.key, it) } - profiles.filter { it.key in allowed }.forEach { profile -> - put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false)) - } - }.values.toList() -} - -/** A batch tracks source failure without replacing saved bodies with empty success. */ -internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { - @Volatile var failed = false - private set - @Volatile var needsSignIn = false - private set - private var oldestSaved: Long? = null - - suspend fun <T> read(call: ReadCall<T>, event: ReadTrigger = trigger): T? { - return when (val result = client.read(call, event).lastOrNull()) { - is ReadResult.Value -> result.saved.value - is ReadResult.Failure -> { - failed = true - if (result.error == ClientError.NeedsSignIn) needsSignIn = true - result.saved?.let { saved -> synchronized(this) { oldestSaved = minOf(oldestSaved ?: Long.MAX_VALUE, saved.savedAtMillis) } } - result.saved?.value - } - null -> null - } - } - - fun savedTime(): String? = oldestSaved?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt deleted file mode 100644 index 76533ebd..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import com.github.damontecres.wholphin.data.model.RtMark -import com.github.damontecres.wholphin.data.model.SourceRatings -import kotlinx.coroutines.flow.lastOrNull -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.Rating -import org.justinvisser.wolfskin.design.LibraryRating -import org.justinvisser.wolfskin.design.ScreenRatings - -/** Failed reads are not cached as absence; saved observations survive offline. */ -class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { - override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? { - val media = if (type == "movie") MediaType.MOVIE else MediaType.TV - val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE - val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) { - is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) - is ReadResult.Failure -> if (result.error == ClientError.NeedsSignIn) return emptyList() else result.saved?.value?.ratings?.screenRatings(type) ?: return null - null -> return null - } - if (type != "episode" || season == null || episode == null) return ratings - val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull() - if (result is ReadResult.Failure && result.error == ClientError.NeedsSignIn) return emptyList() - val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } - val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() } - return ratings.withEpisode(vote?.voteAverage, vote?.voteCount) - } -} - -internal fun List<LibraryRating>.withEpisode(score: Double?, votes: Long?): List<LibraryRating> { - val observed = SourceRatings.episodeScore(score, votes) ?: return this - return filterNot { it.source == "tmdb" } + LibraryRating("tmdb", observed, episode = true) -} - -/** Use the app's existing source precedence and label rules, not a second interpretation. */ -internal fun List<Rating>.screenRatings(type: String): List<LibraryRating> { - val observed = filter { it.state == Rating.State.OBSERVED } - fun source(source: Rating.Source) = observed.firstOrNull { it.source == source } - val critics = source(Rating.Source.RT_CRITICS) - val audience = source(Rating.Source.RT_AUDIENCE) - val imdb = source(Rating.Source.IMDB).takeIf { type == "movie" } - val tmdb = source(Rating.Source.TMDB).takeIf { (it?.votes ?: 0) > 0 } - val ratings = SourceRatings.of(critics?.score, critics?.status, audience?.score, audience?.status, - imdb?.score, imdb?.votes, tmdb?.score, tmdb?.votes) - fun RtMark.label(): String? = when (this) { - RtMark.FRESH -> "Fresh"; RtMark.ROTTEN -> "Rotten"; RtMark.UPRIGHT -> "Upright"; RtMark.SPILLED -> "Spilled"; RtMark.UNKNOWN -> null - } - return buildList { - ratings.rtCritics?.let { add(LibraryRating("rtCritics", it.percent.toDouble(), it.mark.label())) } - ratings.rtAudience?.let { add(LibraryRating("rtAudience", it.percent.toDouble(), it.mark.label())) } - ratings.imdb?.let { add(LibraryRating("imdb", it)) } - ratings.tmdb?.let { add(LibraryRating("tmdb", it)) } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt deleted file mode 100644 index 5ba52caa..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import java.io.Closeable -import java.security.MessageDigest -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.Operator -import timber.log.Timber - -/** A selected Jellyfin login owns every provider and all in-memory observations. */ -data class ConductarrIdentity( - val person: String, - val client: ConductarrClient, - val provider: ConductarrProvider, - val operator: Operator? = null, - val error: ClientError? = null, - val signedIn: Boolean = false, -) - -/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ -class ConductarrSession( - private val cache: CacheStore, - private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), -) : Closeable { - private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null) - val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow() - private var selection: ByteArray? = null - @Volatile private var generation = 0L - - fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) { - val fingerprint = MessageDigest.getInstance("SHA-256") - .digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray()) - val previous: ConductarrClient? - val currentGeneration: Long - synchronized(this) { - if (!retry && selection?.contentEquals(fingerprint) == true) return - generation++ - currentGeneration = generation - previous = mutableIdentity.value?.client - mutableIdentity.value = null - selection = fingerprint - } - try { - val client = if (token.isNullOrBlank()) null else try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> - if (event == ClientEvent.NEEDS_SIGN_IN) { - mutableIdentity.update { - if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it - } - } - }) - } catch (_: IllegalArgumentException) { null } - synchronized(this) { - if (generation == currentGeneration) { - mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) } - } else dispose(client) - } - } finally { - dispose(previous) - } - } - - /** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */ - suspend fun authenticate() { - val selected = mutableIdentity.value ?: return - selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result -> - when (result) { - is ReadResult.Value -> mutableIdentity.update { - if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it - } - is ReadResult.Failure -> mutableIdentity.update { - if (it?.client !== selected.client) it - else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error) - else it.copy(error = result.error) - } - } - } - } - - /** Remove read authority synchronously; socket disposal finishes independently on IO. */ - @Synchronized - override fun close() { - generation++ - val previous = mutableIdentity.value?.client - mutableIdentity.value = null - selection = null - dispose(previous) - } - - private fun dispose(client: ConductarrClient?) { - if (client == null) return - // The application scope outlives the screen replaced by a person switch. - disposalScope.launch(Dispatchers.IO) { - try { - client.close() - } catch (_: Exception) { - Timber.w("Conductarr client disposal failed") - } - } - } -} - -/** Person ids partition disk entries even if a caller accidentally supplies the same credential twice. */ -private class PersonCacheStore(private val store: CacheStore, private val person: String) : CacheStore { - override fun read(scope: String, key: String) = store.read("$person:$scope", key) - override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write("$person:$scope", key, entry, capacity) - override fun remove(scope: String, key: String) = store.remove("$person:$scope", key) - override fun clear(scope: String) = store.clear("$person:$scope") -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt deleted file mode 100644 index 9030249d..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* -import org.justinvisser.wolfskin.design.* - -/** Conductarr never grants playback: only a resolved Jellyfin page can supply held/play identities. */ -internal class ConductarrTitlePages(private val identity: ConductarrIdentity) { - fun title(tmdb: Long, media: MediaType, held: TitlePageData? = null): Flow<TitlePageData> = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { - val page = titleSnapshot(tmdb, media, held, trigger) - if (trigger != ReadTrigger.CACHE_ONLY || page.status == TitleReadStatus.Ready) emit(page) - } - } - - private suspend fun titleSnapshot(tmdb: Long, media: MediaType, held: TitlePageData?, trigger: ReadTrigger): TitlePageData = coroutineScope { - val client = identity.client - val reads = SnapshotReads(client, trigger) - val profile = identity.operator!!.recommendationProfile - val detailJob = async { reads.read(TvRoutes.title(tmdb, media)) } - val castJob = async { reads.read(TvRoutes.cast(tmdb, media)) } - val relatedJob = async { reads.read(TvRoutes.related(tmdb, media, 1)) } - val enrichmentJob = async { reads.read(TvRoutes.enrichment(tmdb, media)) } - val displayJob = async { reads.read(TvRoutes.display(tmdb, media)) } - val ratingsJob = async { reads.read(TvRoutes.ratings(tmdb, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val readinessJob = async { reads.read(TvRoutes.readiness(tmdb, media, identity.operator.watchUser)) } - val receiptJob = async { reads.read(TvRoutes.requests(tmdb, media)) } - val feedJob = async { reads.read(TvRoutes.feed(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val hiddenJob = async { reads.read(TvRoutes.dismissals(profile), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) } - val seasonJobs = if (media == MediaType.TV && held != null) { - (listOfNotNull(held.selectedSeason) + listOfNotNull(held.playTarget, held.watchTarget).filter { it.kind == "episode" }.map { it.season }) - .distinct().associateWith { number -> async { reads.read(TvRoutes.season(tmdb, number)) } } - } else emptyMap() - val detail = detailJob.await() - val enrichment = enrichmentJob.await() - val display = displayJob.await() - val raw = detail?.title ?: enrichment?.title - val mediaName = media.name.lowercase() - val sourceItem = raw?.titleItem(identity.provider) ?: TitleItem("$mediaName:$tmdb", display?.title.orEmpty(), mediaName, poster = display?.posterUrl, tmdbId = tmdb) - val genres = detail?.genres ?: enrichment?.genres - val seasons = detail?.seasons ?: enrichment?.seasons - val cast = castJob.await() - val related = relatedJob.await()?.items.orEmpty().map { it.titleItem(identity.provider) } - val readiness = readinessJob.await() - val receipt = receiptJob.await()?.requests.orEmpty().filter { it.requester == identity.operator.requester || it.actor.requester == identity.operator.requester }.maxByOrNull { it.id } - val stages = readiness?.stages.orEmpty().takeIf { receipt?.requester == identity.operator.requester }.orEmpty() - val stage = when { - receipt == null -> null - stages.any { it.state == ReadinessStage.State.FAILED } -> "Failed" - stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state == ReadinessStage.State.DONE } -> "Ready" - stages.any { it.name == ReadinessStage.Name.VISIBILITY && it.state in listOf(ReadinessStage.State.PARTIAL, ReadinessStage.State.IN_PROGRESS) } -> "Almost ready" - stages.any { it.name == ReadinessStage.Name.ACQUISITION && it.state == ReadinessStage.State.IN_PROGRESS } -> "Downloading" - receipt?.state == RequestReceipt.State.SETTLED -> "Requested" - receipt != null -> "Checking" - else -> null - } - val feed = feedJob.await() - val hidden = hiddenJob.await()?.dismissals.orEmpty().any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName } - val votes = seasonJobs.mapValues { (_, job) -> job.await()?.episodes.orEmpty().associateBy { it.number } } - val observedRatings = ratingsJob.await()?.ratings.orEmpty() - val ratings = if (reads.needsSignIn) emptyList() else observedRatings.screenRatings(mediaName) - val item = (held?.item ?: sourceItem).copy( - runtimeSeconds = held?.item?.runtimeSeconds?.takeIf { it > 0 } ?: ((detail?.runtime ?: enrichment?.runtime ?: 0) * 60), - genres = genres ?: held?.item?.genres.orEmpty(), seasons = held?.item?.seasons ?: seasons.orEmpty().size, - ratings = ratings, tmdbId = tmdb, - ) - fun rated(target: TitleItem): TitleItem { - val episode = votes[target.season]?.get(target.episode.toLong()) - return target.copy(tmdbId = tmdb, ratings = if (reads.needsSignIn) emptyList() else if (target.kind == "episode") ratings.withEpisode(episode?.voteAverage, episode?.voteCount) else ratings) - } - val base = held ?: TitlePageData(item) - base.copy(item = item, watchTarget = rated(base.watchTarget), playTarget = base.playTarget?.let(::rated), - cast = cast?.items?.sortedBy { it.order }?.map { TitlePerson(it.performerId.toString(), it.name, it.character, catalogueImage(it.profileUrl)) } ?: base.cast, - castStatus = if (cast == null && base.cast.isEmpty()) TitleReadStatus.Unavailable else TitleReadStatus.Ready, - related = related, episodes = base.episodes.map(::rated), - status = if (raw != null || held != null || display?.title != null) TitleReadStatus.Ready else TitleReadStatus.Unavailable, - connection = when { reads.needsSignIn -> TitleConnection.SignedOut; reads.failed || identity.error != null -> TitleConnection.Offline; else -> TitleConnection.SignedIn }, - requestedStage = stage, requestActor = receipt?.actor?.name, - requestFor = receipt?.takeIf { it.requester != identity.operator.requester }?.requesterUsername, - hidden = hidden, pick = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).any { it.tmdbId == tmdb && it.mediaType.name.lowercase() == mediaName }, - savedAt = reads.savedTime() ?: held?.savedAt, - seasonNumbers = held?.seasonNumbers ?: seasons.orEmpty().map { it.number.toInt() }.filter { it > 0 }, - requestsAllowed = identity.operator.capabilities.requestMedia && !reads.failed && !reads.needsSignIn, - ) - } - - fun person(id: Long, page: Int = 1): Flow<ConductarrPersonPage> = flow { - for (trigger in listOf(ReadTrigger.CACHE_ONLY, ReadTrigger.OPEN)) { - val reads = SnapshotReads(identity.client, trigger) - val result = coroutineScope { - val detail = async { reads.read(TvRoutes.performer(id)) } - val credits = async { reads.read(TvRoutes.filmography(id, page)) } - detail.await() to credits.await() - } - val detail = result.first - val credits = result.second - if (trigger == ReadTrigger.CACHE_ONLY && detail?.name == null) continue - emit(ConductarrPersonPage(detail?.name?.let { TitlePerson(id.toString(), it, portrait = catalogueImage(detail.profileUrl), biography = detail.biography.orEmpty()) }, - credits?.items.orEmpty().map { it.title.titleItem(identity.provider).copy(character = it.credits.orEmpty().map { credit -> credit.character }.filter { it.isNotBlank() }.distinct().joinToString(" / ")) }, - credits != null && credits.page < (credits.totalPages ?: 1), reads.savedTime(), reads.needsSignIn, reads.failed)) - } - } -} - -internal data class ConductarrPersonPage(val person: TitlePerson?, val credits: List<TitleItem>, val hasMore: Boolean, val savedAt: String?, val needsSignIn: Boolean, val failed: Boolean) - -private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem( - "${mediaType.name.lowercase()}:$tmdbId", title, mediaType.name.lowercase(), releaseDate?.take(4)?.toIntOrNull(), - overview = overview, poster = catalogueImage(posterUrl), backdrop = catalogueImage(backdropUrl), - otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, -) - -internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( - item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, - genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, - runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, - seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, - heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), -) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt deleted file mode 100644 index 439bca3d..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitleProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import kotlinx.coroutines.flow.Flow -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.conductarr.model.* - -/** Titles can adopt these reads without importing transport or owning another login. */ -interface ConductarrTitleProvider { - fun detail(tmdb: Long, media: MediaType): Flow<ReadResult<Detail>> - fun cast(tmdb: Long, media: MediaType): Flow<ReadResult<TitleCast>> - fun related(tmdb: Long, media: MediaType, page: Int = 1): Flow<ReadResult<Page>> - fun season(tmdb: Long, number: Int): Flow<ReadResult<SeasonDetail>> - fun performer(id: Long): Flow<ReadResult<PerformerDetail>> - fun filmography(id: Long, page: Int = 1): Flow<ReadResult<FilmographyPage>> - fun activity(tmdb: Long, media: MediaType, viewer: String): Flow<ReadResult<ViewerReadiness>> - fun requests(tmdb: Long, media: MediaType): Flow<ReadResult<TitleRequestsBody>> - fun enrichment(tmdb: Long, media: MediaType): Flow<ReadResult<Enrichment>> - fun display(tmdb: Long, media: MediaType): Flow<ReadResult<Display>> -} - -/** All title lifetimes, conditional requests and 401 handling remain in the shared client. */ -internal class ClientTitleProvider(private val client: ConductarrClient) : ConductarrTitleProvider { - override fun detail(tmdb: Long, media: MediaType) = client.read(TvRoutes.title(tmdb, media), ReadTrigger.OPEN) - override fun cast(tmdb: Long, media: MediaType) = client.read(TvRoutes.cast(tmdb, media), ReadTrigger.OPEN) - override fun related(tmdb: Long, media: MediaType, page: Int) = client.read(TvRoutes.related(tmdb, media, page), ReadTrigger.OPEN) - override fun season(tmdb: Long, number: Int) = client.read(TvRoutes.season(tmdb, number), ReadTrigger.OPEN) - override fun performer(id: Long) = client.read(TvRoutes.performer(id), ReadTrigger.OPEN) - override fun filmography(id: Long, page: Int) = client.read(TvRoutes.filmography(id, page), ReadTrigger.OPEN) - override fun activity(tmdb: Long, media: MediaType, viewer: String) = client.read(TvRoutes.readiness(tmdb, media, viewer), ReadTrigger.OPEN) - override fun requests(tmdb: Long, media: MediaType) = client.read(TvRoutes.requests(tmdb, media), ReadTrigger.FORCE) - override fun enrichment(tmdb: Long, media: MediaType) = client.read(TvRoutes.enrichment(tmdb, media), ReadTrigger.OPEN) - override fun display(tmdb: Long, media: MediaType) = client.read(TvRoutes.display(tmdb, media), ReadTrigger.OPEN) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt deleted file mode 100644 index ee29266b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import com.github.damontecres.wholphin.BuildConfig -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.lastOrNull -import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.conductarr.* -import org.justinvisser.wolfskin.design.* - -internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Discover requires the Wolfskin shell") } - -/** Production uses the selected login; explicit fixtures remain deterministic and network-free. */ -@Composable -internal fun rememberDiscoverState( - shell: ShellState, - push: (String) -> Unit, - pop: () -> Unit, - loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } }, - identity: ConductarrIdentity? = null, - fixtures: Boolean = BuildConfig.DEBUG, - retrySignIn: suspend () -> Unit = {}, -): DiscoverState { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() } - val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() - val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) { - if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty() - } - LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { - if (!fixtures && identity?.signedIn == true) identity.provider.profiles() - } - val refresh: suspend () -> DiscoverSnapshot = { - if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) - else { retrySignIn(); DiscoverSnapshot() } - } - val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { - val operations = identity?.takeIf { it.signedIn }?.let { it.provider.operations(it.operator!!) } - ?: SignedOutDiscoverOperations(refresh) - lateinit var loaded: DiscoverState - loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop, - { service, type -> servicePages["${service.id}:$type"].orEmpty() }, - requesters = requesters, - onMore = if (fixtures || identity?.signedIn != true) null else { lane -> - val service = loaded.service - if (service == null) identity.provider.more(lane) - else { - servicePages["${service.id}:${loaded.filter}"] = identity.provider.service(service, loaded.filter, more = lane) - loaded.snapshot.lanes - } - }) - loaded - } - SideEffect { if (!fixtures) state.updateRequesters(requesters) } - var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf<DiscoverState?>(null) } - LaunchedEffect(state, fixtures) { - if (fixtures) { - val fixture = loadFixture(context) - val snapshot = fixture.snapshot(shell.person.key) - val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service) - fixtureState = loaded - if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) - } else if (identity?.signedIn == true) { - identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) } - } - } - LaunchedEffect(state.service?.id, state.filter, state) { - val service = state.service ?: return@LaunchedEffect - if (!fixtures && identity?.signedIn == true) { - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter) - state.invalidateLanes() - } - } - LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) { - if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> - val service = state.service - if (service == null) { - state.updateLanes(identity.provider.readAhead(lane)) - val focused = state.focusedLane - if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { - state.updateLanes(identity.provider.more(focused.id)) - } - } else { - val index = state.lanes.indexOfFirst { it.id == lane } - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2) - val focused = state.focusedLane - if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) { - servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id) - } - state.invalidateLanes() - } - } - } - val result = if (fixtures) fixtureState ?: state else state - LaunchedEffect(shell.route, result) { - result.enterRoute(shell.route) - if (shell.route == "discover" && !fixtures && identity?.signedIn == true && result.snapshot.status != DiscoverStatus.Loading) result.refresh() - } - LaunchedEffect(result.snapshot.savedAt, shell.route) { if (shell.route.startsWith("discover")) shell.view.savedAt = result.snapshot.savedAt } - return result -} - -private fun readDiscoverFixture(context: Context): DiscoverFixture { - val json = context.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() } - return DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" } -} - -private class DebugDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { - override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Receipt, actor) - override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) - override suspend fun hide(profile: String, title: DiscoverTitle) = true - override suspend fun restore(profile: String, title: DiscoverTitle) = true - override suspend fun refresh() = snapshot -} - -private class SignedOutDiscoverOperations(private val retry: suspend () -> DiscoverSnapshot) : DiscoverOperations { - override suspend fun request(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.Refused) - override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) - override suspend fun hide(profile: String, title: DiscoverTitle) = false - override suspend fun restore(profile: String, title: DiscoverTitle) = false - override suspend fun refresh() = retry() -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt deleted file mode 100644 index c8ab24cc..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ /dev/null @@ -1,218 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.JellyfinServer -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.HomeLibraryCache -import org.justinvisser.wolfskin.tv.read.HomeReadFacade - -/** The Home entry owns its source reads and upstream navigation, never the design module. */ -@HiltViewModel -class WolfskinHomeViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val home: HomeReadFacade, - private val cache: HomeLibraryCache, - private val navigation: NavigationManager, - private val seeAll: WolfskinSeeAllLists, - private val conductarr: ConductarrSession, -) : ViewModel() { - val conductarrState get() = conductarr.identity - val audiences get() = home.audiences - var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) - private set - var toast by mutableStateOf<String?>(null) - private set - private var catalogueJob: kotlinx.coroutines.Job? = null - private var sourceScope: String? = null - private var loadRevision = 0L - private var catalogueSnapshot = DiscoverSnapshot() - private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null - - suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { - catalogueJob?.cancel() - withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } - if (retry && conductarr.identity.value?.signedIn != true) { - withContext(Dispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) - } - } - loadLibrary(server, user, person, retry) - } - - /** The authenticated read path is shared by release and offline fixture-driven tests. */ - internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { - val revision = ++loadRevision - val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?: - (person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester)) - if (!matchesUser) { - snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false) - return - } - val scope = "${server.id}:${user.id}" - if (sourceScope != scope) { - sourceScope = scope - toast = null - snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading) - } - val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } } - if (revision != loadRevision) return - var libraryCopy = saved?.first ?: HomeLibrary() - var libraryAvailable = true - var librarySavedAt: String? = null - val identity = conductarr.identity.value - var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) - fun publish() { - if (revision != loadRevision || sourceScope != scope) return - val status = when (catalogueCopy.status) { - DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut - DiscoverStatus.Loading -> HomeAvailability.Loading - DiscoverStatus.Unavailable -> HomeAvailability.Unavailable - else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready - } - snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable), - status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt) - } - catalogueSnapshot = catalogueCopy - publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } } - if (saved != null) publish() - if (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch { - if (retry && !identity.signedIn) conductarr.authenticate() - val source = conductarr.identity.value - if (source == null || source.client !== identity.client) return@launch - if (!source.signedIn) { - publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable)) - return@launch - } - source.provider.discover(source.operator!!.recommendationProfile, retry).collect { catalogue -> - if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue) - } - } - try { - val library = withContext(Dispatchers.IO) { home.read(user.id) } - val savedAt = System.currentTimeMillis() - libraryCopy = library - publish() - withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (revision != loadRevision) return - libraryAvailable = false - librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } - publish() - if (retry) showToast(RecoveryMessage.StillUnreachable) - } - } - - suspend fun readAhead(lane: String?) { - val identity = conductarr.identity.value ?: return - if (!identity.signedIn || ConductarrPreference.fixtures) return - val revision = loadRevision - val lanes = identity.provider.readAhead(lane) - if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes)) - } - - fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { - when (action) { - HomeAction.Discover -> openRoot(shell, "discover") - HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } - is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) - is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) - is HomeAction.Play -> viewModelScope.launch { - val selectedId = shell.person.jellyfinUserId - val matchesUser = if (selectedId != null) selectedId == user.id.toString() else - shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) - if (!snapshot.jellyfinAvailable || !matchesUser) { - showToast(RecoveryMessage.PlaybackUnavailable) - return@launch - } - try { - val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } - if (id == null) showToast(RecoveryMessage.PlaybackUnavailable) - else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - showToast(RecoveryMessage.PlaybackUnavailable) - } - } - } - } - - private fun openCatalogueGrid(row: HomeRow, shell: ShellState) { - val identity = conductarr.identity.value - val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id } - val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ -> - if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed") - val next = identity.provider.more(lane.id).first { it.id == lane.id } - if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable") - LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore) - } else null - seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore) - } - - private fun openRoot(shell: ShellState, route: String) { - shell.enterDestination(route) - shell.focusNav(shell.tabs.indexOf(route)) - shell.key(ShellKey.Ok) - } - - private fun showToast(message: String) { - toast = message - viewModelScope.launch { delay(WolfskinTokens.Motion.toast.toLong()); if (toast == message) toast = null } - } -} - -/** Complete catalogue projections feed the shared See all grid without another source read. */ -internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( - id = id, title = title, type = type, year = year, runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasons, - poster = poster, played = played, progress = runtimeSeconds?.let { if (it > 0) (resumeSeconds / it).toFloat() else 0f } ?: 0f, - addedAt = addedAt, tmdbId = tmdbId?.toString(), - availability = if (held) "In your library" else otherOwner?.let { "In $it’s library" }.orEmpty(), - badge = if (held) "held" else null, - ratings = ratings.map { rating -> - when (rating.mark) { - "fresh" -> LibraryRating("rtCritics", rating.score, "Fresh") - "rotten" -> LibraryRating("rtCritics", rating.score, "Rotten") - "tomato" -> LibraryRating("rtCritics", rating.score) - "upright" -> LibraryRating("rtAudience", rating.score, "Upright") - "spilled" -> LibraryRating("rtAudience", rating.score, "Spilled") - else -> LibraryRating(rating.mark, rating.score) - } - }, -) - -/** The shell keeps its focus memory while playback or later-slice screens are pushed above Home. */ -@Composable -fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { - val identity by model.conductarrState.collectAsState() - LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person) } - val view = shell.rememberedView("home") - LaunchedEffect(view.row, model.snapshot.rows) { - val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id - model.readAhead(lane) - } - Box(modifier) { - val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot - HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } - model.toast?.let { RecoveryToast(it) } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt deleted file mode 100644 index 2c88cc9b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.WolfskinTokens - -/** Foreground refreshes once; only an open sheet or an unsettled request keeps polling. */ -@Composable -internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) { - val owner = LocalLifecycleOwner.current - LaunchedEffect(state, state.person.key, owner) { - owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - refreshRequestsWhileStarted(state, refresh) - } - } -} - -/** Cancellation on leaving STARTED stops polling; reentry performs a fresh foreground read. */ -internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () -> Unit) { - refresh() - while (currentCoroutineContext().isActive) { - delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L) - if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh() - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt deleted file mode 100644 index 5984cb5a..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.data.model.JellyfinUser -import org.json.JSONObject -import org.justinvisser.wolfskin.design.ShellPerson - -/** Prototype identities are packaged exclusively in debug assets, never release resources. */ -fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> { - if (ConductarrPreference.fixtures) { - val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } - val profiles = json.getJSONArray("profiles") - return List(profiles.length()) { index -> - val person = profiles.getJSONObject(index) - ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) - } - } - return saved.map { user -> - val name = user.name.orEmpty() - ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false, user.id.toString()) - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt deleted file mode 100644 index a5b32953..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRequests.kt +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.BuildConfig -import org.justinvisser.wolfskin.design.ComingInSnapshot -import org.justinvisser.wolfskin.design.ComingInStatus -import org.justinvisser.wolfskin.design.readComingInFixture - -/** Release stays unavailable until Conductarr sign-in supplies an authenticated generated client. */ -fun readShellRequests(context: Context): ComingInSnapshot { - if (!BuildConfig.DEBUG) return ComingInSnapshot() - val json = context.assets.open("wolfskin-requests.json").bufferedReader().use { it.readText() } - val items = readComingInFixture(json) { name -> - context.assets.open("wolfskin-requests/$name").use { it.readBytes() } - } - return ComingInSnapshot(items.take(6), ComingInStatus.Ready, signedIn = true) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt deleted file mode 100644 index dffa611b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import com.github.damontecres.wholphin.ui.nav.Destination -import org.jellyfin.sdk.model.api.BaseItemKind - -/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */ -fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? = - when (destination) { - is Destination.Home -> if (wolfskinEnabled) null else destination - is Destination.MediaItem -> if (wolfskinEnabled && destination.type in TITLE_KINDS) null else destination - is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null - else -> destination - } - -private val LIBRARY_ROUTES = setOf("movies", "shows", "anime") -private val TITLE_KINDS = setOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE, BaseItemKind.PERSON) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt deleted file mode 100644 index e457aa86..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ /dev/null @@ -1,333 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.foundation.layout.Box -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.type -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.justinvisser.wolfskin.design.DiscoverOverlay -import org.justinvisser.wolfskin.design.ShellKey -import org.justinvisser.wolfskin.design.readShellKey -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -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.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import com.github.damontecres.wholphin.data.ServerRepository -import org.justinvisser.wolfskin.conductarr.MediaType -import org.justinvisser.wolfskin.design.PersonState -import org.justinvisser.wolfskin.design.PersonSurface -import org.justinvisser.wolfskin.design.RecoveryMessage -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.TitleAction -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitleReadStatus -import org.justinvisser.wolfskin.design.TitleState -import org.justinvisser.wolfskin.design.TitleSurface -import org.justinvisser.wolfskin.tv.read.TitleReadFacade -import java.util.UUID -import javax.inject.Inject - -/** Title and performer routes remain within the single existing Wolfskin destination subtype. */ -fun titleRoute(id: String): String = "title/${Uri.encode(id)}" -fun personRoute(id: String): String = "person/${Uri.encode(id)}" - -/** Each navigation entry owns its title focus and selections across nested person/title pages. */ -@HiltViewModel -class WolfskinTitleViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val facade: TitleReadFacade, - private val fixtureProvider: TitleFixtureProvider, - private val navigation: NavigationManager, - val conductarr: ConductarrSession, - private val repository: ServerRepository, -) : ViewModel() { - var title by mutableStateOf<TitleState?>(null) - private set - var person by mutableStateOf<PersonState?>(null) - private set - private var route = "" - private lateinit var viewer: ShellPerson - private lateinit var preferences: UserPreferences - private var fixture: TitleFixture? = null - private var personReadPending = false - private var personReadFinished = false - private var loadJob: Job? = null - private val saveMutex = Mutex() - private var performerPage = 1 - var conductarrAction: ((TitleAction) -> Unit)? = null - - fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) - fun popRoute() = navigation.goBack() - private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } - - /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ - fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { - if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return - route = destination - loadJob?.cancel() - person = null - fixture = null - personReadPending = false - personReadFinished = false - performerPage = 1 - viewer = selected - preferences = userPreferences - val id = Uri.decode(destination.substringAfter('/')) - title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) - loadJob = viewModelScope.launch { - if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { - val source = fixtureProvider.read() - fixture = source - if (destination.startsWith("person/")) { - val (actor, credits) = withContext(Dispatchers.IO) { source.person(id, selected.key) } - person = PersonState(actor, credits, ::handle) - } else { - val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } - val key = "${viewer.key}:${data.item.id}" - val watchKey = "${viewer.key}:${data.watchTarget.id}" - title = TitleState(data.copy( - selectedVersion = fixtureSelections.getString("$key:version", null), - selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null, - selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1), - watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)), - hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), - ), viewer, ::handle) - } - } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) - } - } - - private suspend fun readTitle(id: String, retry: Boolean = false) { - val current = title ?: return - val uuid = id.toUuidOrNull() - var held: TitlePageData? = null - val catalogue = if (uuid == null) id.split(':').takeIf { it.size == 2 && it[0] in listOf("movie", "tv") && it[1].toLongOrNull() != null } else null - if (uuid == null && catalogue == null) { current.update(current.data.copy(status = TitleReadStatus.Unavailable)); return } - try { - val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } - if (title !== current) return - if (heldId != null) { - if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } - held = facade.read(heldId, current.takeIf { it.data.status == TitleReadStatus.Ready }?.season, preferences) - current.update(held) - } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (current.data.status == TitleReadStatus.Ready) { - if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() - held = current.data - } else if (uuid != null) current.update(current.data.copy(status = TitleReadStatus.Unavailable)) - if (uuid != null && (current.data.status == TitleReadStatus.Ready || retry)) current.toast = RecoveryMessage.StillUnreachable - } - val identity = conductarr.identity.value?.takeIf { it.signedIn } - val tmdb = catalogue?.get(1)?.toLong() ?: held?.item?.tmdbId - if (identity != null && tmdb != null) { - val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV - ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> - if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) - } - } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) - } - - private suspend fun readPerson(id: String, retry: Boolean = false) { - if (personReadPending || personReadFinished) return - if (id.toLongOrNull() != null) { readPerformer(id.toLong(), retry); return } - val uuid = id.toUuidOrNull() ?: return - val owner = viewer.key - personReadPending = true - try { - if (person == null) facade.readPersonSaved(uuid)?.let { saved -> - if (owner == viewer.key) person = newPerson(saved.person, saved.credits).apply { savedAt = saved.savedAt } - } - val start = if (person?.savedAt != null) 0 else person?.credits?.size ?: 0 - val result = facade.readPerson(uuid, start) - if (owner != viewer.key) return - val current = person - val credits = if (start == 0) result.credits else (current?.credits.orEmpty() + result.credits).distinctBy { it.id } - if (current == null) person = newPerson(result.person, credits) else current.update(result.person, credits) - personReadFinished = result.credits.size < 200 - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - if (owner == viewer.key) { - person?.toast = RecoveryMessage.StillUnreachable - if (person == null) { - title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) - if (retry) title?.toast = RecoveryMessage.StillUnreachable - } - } - } finally { if (owner == viewer.key) personReadPending = false } - } - - private suspend fun readPerformer(id: Long, retry: Boolean) { - val identity = conductarr.identity.value?.takeIf { it.signedIn } ?: run { - title?.let { it.update(it.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut)) } - return - } - val currentTitle = title - personReadPending = true - var failed = false - try { - ConductarrTitlePages(identity).person(id, performerPage).collect { page -> - if (title !== currentTitle || conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return@collect - if (page.person != null) { - val current = person - val credits = if (performerPage == 1) page.credits else (current?.credits.orEmpty() + page.credits).distinctBy { it.id } - if (current == null) person = PersonState(page.person, credits, ::handle) { viewModelScope.launch { readPerson(id.toString()) } } - person?.update(page.person, credits, page.savedAt) - if (page.failed && retry) person?.toast = RecoveryMessage.StillUnreachable - } else title?.update(requireNotNull(title).data.copy(status = TitleReadStatus.Unavailable)) - personReadFinished = !page.hasMore && !page.failed - failed = page.failed - } - if (!personReadFinished && !failed) performerPage++ - } finally { personReadPending = false } - } - - internal suspend fun retrySignIn() { - val user = repository.currentUser ?: return - val server = repository.currentServer ?: return - withContext(Dispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) - } - conductarr.authenticate() - } - - private fun newPerson(actor: org.justinvisser.wolfskin.design.TitlePerson, credits: List<TitleItem>) = - PersonState(actor, credits, ::handle) { viewModelScope.launch { readPerson(actor.id) } } - - private fun handle(action: TitleAction) { - when (action) { - TitleAction.Pop -> navigation.goBack() - is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) - is TitleAction.OpenTitle -> navigation.navigateTo(action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { - "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE - "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE - else -> org.jellyfin.sdk.model.api.BaseItemKind.SERIES - }) } ?: Destination.Wolfskin(titleRoute(action.item.id))) - is TitleAction.Play -> play(action.item.id.toUuidOrNull(), if (action.restart) 0 else action.item.resumeSeconds * 1000) - is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), 0) - else if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." - is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) - is TitleAction.Season -> { - loadJob?.cancel() - loadJob = viewModelScope.launch { - val current = title ?: return@launch - current.showSeasonLoading() - val source = fixture - if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) - else { - val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } - current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, - selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) - } - } - } - TitleAction.Retry -> viewModelScope.launch { - if (conductarr.identity.value?.signedIn != true) retrySignIn() - personReadFinished = false - performerPage = 1 - if (fixture == null) { - if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true) - } - else title?.toast = RecoveryMessage.StillUnreachable - } - is TitleAction.Request, is TitleAction.Requests -> requireNotNull(conductarrAction).invoke(action) - is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply() - else requireNotNull(conductarrAction).invoke(action) - } - } - - private fun play(id: UUID?, position: Long) { - viewModelScope.launch { - try { - if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") - facade.preparePlayback(id) - navigation.navigateTo(Destination.Playback(id, position)) - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - title?.toast = RecoveryMessage.PlaybackUnavailable - } - } - } - - private fun savePlayback(action: TitleAction) { - val current = title ?: return - if (fixture != null) { - val edits = fixtureSelections.edit() - when (action) { - is TitleAction.Version -> edits.putString("${viewer.key}:${action.item.id}:version", action.version.id) - is TitleAction.Track -> { - action.audio?.let { edits.putInt("${viewer.key}:${action.item.id}:audio", it) } - edits.putInt("${viewer.key}:${action.item.id}:subtitle", action.subtitle) - } - is TitleAction.Watched -> edits.putBoolean("${viewer.key}:${action.item.id}:watched", action.watched) - else -> Unit - } - edits.apply() - current.confirmPlayback(action) - return - } - viewModelScope.launch { - saveMutex.withLock { - try { facade.apply(action); current.confirmPlayback(action) } - catch (exception: Exception) { - if (exception is CancellationException) throw exception - current.rejectPlayback(action) - current.toast = "Couldn’t save that change. Try again." - } - } - } - } -} - -/** The screen's own Back handler closes its sheet before delegating a navigation pop. */ -@Composable -fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel(), fixtures: Boolean = ConductarrPreference.fixtures) { - val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() - LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } - val title = viewModel.title?.takeIf { it.person.key == shell.person.key } - val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null - LaunchedEffect(title?.data?.status) { - if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false - } - val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = title != null) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) - } - Box(modifier.onPreviewKeyEvent { event -> - if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { - if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) - true - } ?: false - }) { - if (person != null && route.startsWith("person/")) PersonSurface(person, shell) - else if (title != null) TitleSurface(title, shell) - if (requests != null) DiscoverOverlay(requests) - } -} - -private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { null } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt deleted file mode 100644 index 3e9709aa..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt +++ /dev/null @@ -1,144 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import com.github.damontecres.wholphin.BuildConfig -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton -import org.json.JSONArray -import org.json.JSONObject -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitlePerson -import org.justinvisser.wolfskin.design.LibraryRating -import org.justinvisser.wolfskin.design.TitleTrack -import org.justinvisser.wolfskin.design.TitleVersion -import java.util.Locale - -/** The locked prototype is projected only by debug callers and offline JVM captures. */ -class TitleFixture(private val fixture: JSONObject) { - private val profiles = fixture.getJSONArray("profiles").objects() - private val cast = fixture.getJSONObject("cast") - private val availableArt = fixture.getJSONArray("availableArt").let { array -> (0 until array.length()).map { array.getString(it) }.toSet() } - val people: List<ShellPerson> = profiles.map { ShellPerson(it.getString("key"), it.getString("name"), it.getString("initial"), it.getJSONObject("destinations").has("Anime")) } - private val all = profiles.flatMap { profile -> listOf("movies", "shows", "anime", "discover").flatMap { profile.getJSONArray(it).objects() } }.distinctBy { key(it) } - private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy { key(it) } - - /** Each viewer gets their own ownership, resume target and episode watch identity. */ - fun title(id: String, person: String, season: Int? = null): TitlePageData { - val profile = profiles.first { it.getString("key") == person } - val library = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } - val episodes = listOf("resume", "nextUp").flatMap { profile.getJSONArray(it).objects() } - val source = (all + episodes).first { it.getString("id") == id || key(it) == id || it.getString("title") == id } - val selectedEpisode = source.takeIf { it.getString("type") == "episode" } - val container = if (selectedEpisode != null) library.first { it.getString("title") == selectedEpisode.getString("seriesTitle") } else source - val own = library.firstOrNull { key(it) == key(container) } - val item = item(own ?: container, person) - val play = selectedEpisode ?: episodes.firstOrNull { it.optString("seriesTitle") == container.getString("title") } - ?: own?.takeIf { it.optDouble("progress") > 0 } - val target = play?.let { item(it, person) } ?: item.takeIf { it.held && it.kind == "movie" } - val selectedSeason = season ?: play?.optInt("seasonNumber")?.takeIf { it > 0 } ?: 1 - val related = (pool + library).filter { key(it) != key(container) && overlap(it, container) > 0 } - .sortedByDescending { overlap(it, container) }.distinctBy { key(it) }.take(16).map { item(it, person) } - val castMembers = cast.optJSONArray(key(container))?.objects().orEmpty().map { member -> - TitlePerson(member.getString("name"), member.getString("name"), member.optString("character"), art(member.optString("img").takeIf { it.isNotEmpty() }?.let { "assets/cast/$it" })) - } - return TitlePageData(item, play?.let { item(it, person) }, selectedEpisode?.let { item(it, person) } ?: item, - versions = target?.let { versions(it) }.orEmpty(), - trailerUrl = if (item.held && key(container) in TRAILERS) "fixture:trailer" else null, - cast = castMembers, related = related, episodes = episodes.filter { it.optString("seriesTitle") == container.getString("title") && it.optInt("seasonNumber") == selectedSeason }.map { item(it, person) }, - connection = TitleConnection.SignedIn, pick = container.optString("source") == "materialized_personal_recommendation", selectedSeason = selectedSeason) - } - - /** Only real household credits are retained; prototype filler is never promoted to filmography. */ - fun person(name: String, viewer: String): Pair<TitlePerson, List<TitleItem>> { - val credits = all.mapNotNull { title -> - val actor = cast.optJSONArray(key(title))?.objects()?.firstOrNull { it.getString("name") == name } ?: return@mapNotNull null - item(title, viewer).copy(character = actor.optString("character")) - }.sortedWith(compareByDescending<TitleItem> { it.year ?: 0 }.thenBy { it.name.replace(Regex("^(The|A|An) "), "") }) - val actor = cast.keys().asSequence().flatMap { cast.getJSONArray(it).objects() }.first { it.getString("name") == name } - return TitlePerson(name, name, portrait = art(actor.optString("img").takeIf { it.isNotEmpty() }?.let { "assets/cast/$it" })) to credits - } - - private fun item(source: JSONObject, person: String): TitleItem { - val owners = profiles.filter { profile -> listOf("movies", "shows", "anime").any { bucket -> profile.getJSONArray(bucket).objects().any { key(it) == key(source) } } } - val held = source.optString("type") == "episode" || owners.any { it.getString("key") == person } - return TitleItem( - source.getString("id"), source.getString("title"), source.getString("type"), source.optInt("year").takeIf { it > 0 }, - source.optLong("runtimeSeconds"), source.optInt("seasonCount"), source.optString("overview"), - source.optJSONArray("genres")?.let { values -> (0 until values.length()).map { values.getString(it) } }.orEmpty(), - art(source.optString("poster")), art(source.optString("backdrop")), held, source.optBoolean("played"), source.optDouble("resumeSeconds").toLong(), - seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"), - ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") }, - isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", - ) - } - - private fun art(path: String?): String? = path?.takeIf { it in availableArt }?.let { "file:///android_asset/wolfskin-titles/$it" } - - private fun ratings(item: JSONObject): List<LibraryRating> { - val episode = item.optString("type") == "episode" - if (episode) { - val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") } - val base = series?.let { ratings(it) }.orEmpty() - val score = item.optDouble("communityRating", Double.NaN) - return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + LibraryRating("tmdb", score, episode = true) else base - } - val ratings = item.optJSONObject("ratings") ?: JSONObject().apply { - if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating")) - if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating")) - } - return buildList { - if (!ratings.isNull("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() })) - if (!ratings.isNull("rtAudience")) add(LibraryRating("rtAudience", ratings.getDouble("rtAudience"), ratings.optString("rtAudienceLabel").takeIf { it.isNotEmpty() })) - if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(LibraryRating("imdb", ratings.getDouble("imdb"))) - if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(LibraryRating("tmdb", ratings.getDouble("tmdb"))) - }.filter { it.score in 0.0..if (it.source.startsWith("rt")) 100.0 else 10.0 } - } - - private fun versions(item: TitleItem): List<TitleVersion> { - val subtitles = listOf(TitleTrack(2, "English"), TitleTrack(3, "English SDH"), TitleTrack(4, "Dutch")) - fun version(id: String, name: String, video: String, bitrate: Double, container: String, audio: List<TitleTrack>) = - TitleVersion(id, name, video, "$bitrate Mbps", String.format(Locale.US, "%.1f GB", bitrate * item.runtimeSeconds / 8000), container, audio, subtitles) - val standardAudio = listOf(TitleTrack(0, "English · AC3 5.1", true), TitleTrack(1, "English · AAC 2.0")) - return if (item.name == "Locke") listOf( - version("v1", "2160p · HDR10 · Remux", "2160p HEVC Main 10 · HDR10", 58.4, "MKV", listOf(TitleTrack(0, "English · TrueHD 7.1", true), TitleTrack(1, "English · AC3 5.1"))), - version("v2", "1080p · SDR", "1080p H.264 High · SDR", 9.8, "MP4", standardAudio), - ) else listOf(version("v1", "1080p · SDR", "1080p H.264 High · SDR", 8.2, "MKV", standardAudio)) - } - - private fun key(item: JSONObject): String = "${item.getString("type")}:${item.optLong("tmdbId")}" - private fun overlap(first: JSONObject, second: JSONObject): Int { - fun genres(item: JSONObject): Set<String> = item.optJSONArray("genres")?.let { array -> (0 until array.length()).flatMap { - when (val genre = array.getString(it)) { - "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy") - "Action & Adventure" -> listOf("Action", "Adventure") - "Sci-Fi" -> listOf("Science Fiction") - else -> listOf(genre) - } - }.toSet() }.orEmpty() - return genres(first).intersect(genres(second)).size - } - - companion object { - private val TRAILERS = setOf("movie:14069", "movie:210479", "movie:318917", "movie:42994", "movie:123431", "tv:83867", "tv:76479", "tv:1215", "tv:1437", "tv:67070", "movie:1930", "movie:21469", "movie:76203", "tv:60948") - } -} - -/** Every title entry shares one parsed, read-only debug fixture; release callers cannot load it. */ -@Singleton -class TitleFixtureProvider @Inject constructor(@param:ApplicationContext private val context: Context) { - private val fixture by lazy { - TitleFixture(context.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) - } - suspend fun read(): TitleFixture = withContext(Dispatchers.IO) { - check(BuildConfig.DEBUG) { "Title fixtures are debug-only" } - fixture - } -} - -private fun JSONArray.objects(): List<JSONObject> = List(length()) { getJSONObject(it) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt deleted file mode 100644 index f4188e3f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import androidx.compose.runtime.* -import org.justinvisser.wolfskin.design.* - -/** Title reads do not depend on a successful recommendation feed to authorize a request. */ -@Composable -internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String, - identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? { - val scope = rememberCoroutineScope() - val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() - val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() } - LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { - if (!fixtures && identity?.signedIn == true) identity.provider.profiles() - } - val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute, - identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn) - else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) { - identity?.takeIf { it.signedIn }?.let { - DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!), - scope, viewModel::openRoute, viewModel::popRoute, - requesters = requesters) - } - } - SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } - LaunchedEffect(requests, title?.data) { - val data = title?.data ?: return@LaunchedEffect - if (!fixtures && requests != null && data.item.tmdbId != null) { - val item = data.requestTitle(shell.person) - requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), - hidden = if (data.hidden) listOf(item) else emptyList(), - status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, - savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) - } - } - val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } - LaunchedEffect(requests, hidden, requests?.toast) { - val data = title?.data ?: return@LaunchedEffect - if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) - } - val receipt = (requests?.overlay as? DiscoverOverlayState.Request)?.state - LaunchedEffect(receipt?.phase, receipt?.result) { - val data = title?.data ?: return@LaunchedEffect - if (receipt?.phase == RequestPhase.Receipt) title.update(data.copy(requestedStage = receipt.result?.stage ?: "Requested", - requestActor = receipt.result?.actor, requestFor = receipt.selectedPerson.name.takeIf { receipt.selectedPerson.key != shell.person.key })) - } - SideEffect { - viewModel.conductarrAction = { action -> - val current = viewModel.title - when (action) { - is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) - is TitleAction.Requests -> shell.openRequests() - is TitleAction.Hide -> if (current != null && requests != null) { - val item = current.data.requestTitle(shell.person) - if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) - requests.overlay = null // TitleState already owns the native hide receipt and Undo. - } - else -> error("Not a Conductarr title action") - } - } - } - return requests -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt deleted file mode 100644 index 0dce1f9f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri - -/** Only an installed app that handles the YouTube scheme may receive a remote trailer. */ -class TitleTrailer(private val context: Context) { - /** A missing handler leaves the title in place rather than falling back to a browser. */ - fun open(url: String): Boolean { - val source = Uri.parse(url) - val video = when (source.host?.lowercase()) { - "youtu.be" -> source.pathSegments.firstOrNull() - "youtube.com", "www.youtube.com", "m.youtube.com" -> source.getQueryParameter("v") - ?: source.pathSegments.takeIf { it.firstOrNull() in setOf("shorts", "embed") }?.getOrNull(1) - else -> null - } ?: return false - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube:$video")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - val handler = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - .firstOrNull { it.activityInfo.exported } ?: return false - intent.setPackage(handler.activityInfo.packageName) - return try { - context.startActivity(intent) - true - } catch (_: ActivityNotFoundException) { - false - } catch (_: SecurityException) { - false - } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt deleted file mode 100644 index aed8ec7b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ /dev/null @@ -1,361 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay -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.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropResult -import com.github.damontecres.wholphin.services.BackdropService -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.Backdrop -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.nav.DestinationContent -import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.design.PeopleStatus -import org.justinvisser.wolfskin.design.SettingsList -import org.justinvisser.wolfskin.design.ShellAction -import org.justinvisser.wolfskin.design.ShellPerson -import org.justinvisser.wolfskin.design.ShellPlaceholder -import org.justinvisser.wolfskin.design.ShellState -import org.justinvisser.wolfskin.design.ShellSurface -import org.justinvisser.wolfskin.design.WolfskinTheme -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.justinvisser.wolfskin.design.DiscoverOverlay -import org.justinvisser.wolfskin.design.DiscoverScreen - -/** View memory must outlive the upstream setup entry replaced by a user switch. */ -@Singleton -class WolfskinSession @Inject constructor() { - var state by mutableStateOf<ShellState?>(null) - var serverId: String? = null - internal var fixtureMode: Boolean? = null - var onAction: (ShellAction) -> Unit = {} -} - -/** The shell routes only through the existing NavigationManager and real settings pages. */ -@HiltViewModel -class WolfskinShellViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - val session: WolfskinSession, - private val repository: ServerRepository, - private val navigation: NavigationManager, - private val setupNavigation: SetupNavigationManager, - private val backdrop: BackdropService, - val libraries: LibraryReadFacade, - val seeAll: WolfskinSeeAllLists, - val conductarr: ConductarrSession, - artwork: WolfskinArtwork, -) : ViewModel() { - init { artwork.configure(context) } - val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow - private val requestsSnapshot by lazy { readShellRequests(context) } - var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap()) - private set - var signedInPerson by mutableStateOf("") - private set - private var identityJob: kotlinx.coroutines.Job? = null - private var personSwitchJob: kotlinx.coroutines.Job? = null - private val personSwitchMutex = Mutex() - - suspend fun load(server: JellyfinServer, user: JellyfinUser) { - withContext(Dispatchers.IO) { - WolfskinScreenPreference.initialize(context) - ConductarrPreference.initialize(context) - } - identityJob?.cancel() - libraries.audiences = null - if (ConductarrPreference.fixtures) conductarr.close() - else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) } - signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() - session.onAction = { action -> handleAction(action, server, user) } - if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) { - session.serverId = server.id.toString() - session.fixtureMode = ConductarrPreference.fixtures - val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } - session.state = ShellState(people, signedInPerson) { session.onAction(it) } - val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } - val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } - session.state?.updatePeople(savedPeople) - } - identityJob = viewModelScope.launch { - if (!ConductarrPreference.fixtures) loadHousehold(user) - } - try { - libraryDestinations = libraries.readDestinations(user.id) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - libraryDestinations = emptyMap() - } - } - - private suspend fun loadHousehold(user: JellyfinUser) { - conductarr.authenticate() - val identity = conductarr.identity.value ?: return - if (!identity.signedIn || !identity.person.endsWith(":${user.id}")) return - val profiles = identity.provider.profiles()?.profiles.orEmpty() - val audiences = identity.provider.audiences() - if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return - audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } - ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } - val hasAnime = try { - libraries.hasAnimeTitles(user.id) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false - } - if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return - val updated = session.state?.people.orEmpty().map { person -> - val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } - val current = person.jellyfinUserId == user.id.toString() - person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, - name = if (current) identity.operator!!.name else profile?.name ?: person.name, - hasAnime = if (current) hasAnime else person.hasAnime) - } - session.state?.updatePeople(updated) - signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson - } - - /** Foreground recovery retains the current identity and any usable audience mapping. */ - fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? { - if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null - identityJob?.cancel() - return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it } - } - - suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { - withContext(Dispatchers.IO) { - conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, - retry = conductarr.identity.value?.signedIn != true) - } - loadHousehold(user) - } - - fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } - - /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */ - fun refreshRequests() = refreshRequests(retry = false) - - private fun refreshRequests(retry: Boolean) { - val shell = session.state ?: return - val target = shell.requests - val identity = conductarr.identity.value - viewModelScope.launch { - if (ConductarrPreference.fixtures) { - target.update(withContext(Dispatchers.IO) { requestsSnapshot }) - } else if (identity?.signedIn != true) { - target.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) - } else identity.provider.requests().collect { snapshot -> - if (conductarr.identity.value?.client === identity.client) { - target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot()) - } - } - if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { - shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable - } - } - } - - private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { - when (action) { - is ShellAction.Destination -> { - val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) - when { - action.route == "settings" -> navigation.navigateTo(target) - action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target - else -> navigation.replace(target) - } - } - ShellAction.Pop -> navigation.goBack() - is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) - is ShellAction.Person -> selectPerson(action.person, server) - ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } - ShellAction.RetryRequests -> viewModelScope.launch { - if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) - refreshRequests(retry = true) - } - is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}"))) - } - } - - private fun selectPerson(person: ShellPerson, server: JellyfinServer) { - navigation.replace(Destination.Home()) - if (person.key == signedInPerson) return - identityJob?.cancel() - personSwitchJob?.cancel() - signedInPerson = "" - conductarr.close() - libraries.audiences = null - personSwitchJob = viewModelScope.launch { - personSwitchMutex.withLock { - val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } - } - if (saved == null || saved.isProtected) return@launch - try { - val current = repository.changeUser(server, saved) - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.AppContent(current)) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - coroutineContext.ensureActive() - session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } - } - } - } - } -} - -/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */ -fun handOffDestination(group: String): Destination = when (group) { - "advanced" -> Destination.Settings(PreferenceScreenOption.ADVANCED) - "profile" -> Destination.HomeSettings - else -> Destination.Settings(PreferenceScreenOption.BASIC) -} - -private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") } - -/** The one upstream hook preserves destination decorators and full-screen hand-offs. */ -@Composable -fun WolfskinApp( - server: JellyfinServer, - user: JellyfinUser, - navigationManager: NavigationManager, - preferences: UserPreferences, - modifier: Modifier = Modifier, - viewModel: WolfskinShellViewModel = hiltViewModel(), - homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = - { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, -) { - val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() - LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } - androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } - LaunchedEffect(identity?.signedIn, identity?.person) { - if (!ConductarrPreference.fixtures) { - if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) - else viewModel.refreshRequests() - } - } - val loading = remember(user.id) { - ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } - } - val state = viewModel.session.state ?: loading - val discover = rememberDiscoverState( - state, - { route -> navigationManager.navigateTo(Destination.Wolfskin(route)) }, - navigationManager::goBack, - identity = identity, - fixtures = ConductarrPreference.fixtures, - retrySignIn = { - viewModel.retryConductarr(server, user) - }, - ) - RequestsRefresh(state, viewModel::refreshRequests) - WolfskinTheme { - CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, - org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { - Box(modifier) { - val current = navigationManager.backStack.last() - if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { - val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() - Backdrop( - backdrop = backdrop, - drawerIsOpen = false, - backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle, - ) - } - NavDisplay( - backStack = navigationManager.backStack, - onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, - entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), - entryProvider = { destination -> - NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { - // Cached entries must not retain the launch shell after sign-in completes. - val state = LocalShellState.current - val discover = LocalDiscoverState.current - val route = when (destination) { - is Destination.Home -> "home" - is Destination.Wolfskin -> destination.route - is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) - else -> "pushed" - } - val isCurrent = navigationManager.backStack.lastOrNull() == destination - LaunchedEffect(destination, state, isCurrent) { - if (isCurrent && !destination.fullScreen) state.enterDestination(route) - } - if (destination.fullScreen) { - DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) - } else { - BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } - val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) - if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { - val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null - if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { - ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } - } else TitleDestination(route, state, preferences) - } else { - ShellSurface(state, handlesContentKeys = upstream == null, - overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> - when { - (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) - upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) - destination is Destination.Home -> homeContent(state, server, user, contentModifier) - route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> - WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) - destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) - else -> ShellPlaceholder(route, contentModifier) - } - } - } - } - } - }, - ) - } - } - } -} - -/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */ -@Composable -fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) { - when { - route == "settings" -> SettingsList(LocalShellState.current, modifier) - route == "discover" || route.startsWith("discover-") -> DiscoverScreen(LocalDiscoverState.current, modifier) - else -> ShellPlaceholder(route, modifier) - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt deleted file mode 100644 index e546a09b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv - -import android.content.Context -import coil3.ComponentRegistry -import coil3.network.ConnectivityChecker -import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient -import javax.inject.Inject -import javax.inject.Singleton -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import org.justinvisser.wolfskin.design.WolfskinImages - -/** Strip credentials after upstream auth injection, including each redirect's network request. */ -@OptIn(coil3.annotation.ExperimentalCoilApi::class) -@Singleton -class WolfskinArtwork @Inject constructor( - @param:AuthOkHttpClient client: OkHttpClient, - repository: ServerRepository, -) { - private val artworkClient = client.newBuilder().addNetworkInterceptor { chain -> - val request = chain.request() - val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull() - val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme && - request.url.host == jellyfin.host && request.url.port == jellyfin.port - chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build()) - }.build() - private val configure: ComponentRegistry.Builder.() -> Unit = { - add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } })) - } - - /** Install host-scoped networking before any Wolfskin artwork is rendered. */ - fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure) -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt deleted file mode 100644 index 0301d3dc..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ /dev/null @@ -1,106 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.nav.Destination -import java.io.File -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.justinvisser.wolfskin.design.* -import org.justinvisser.wolfskin.tv.read.LibraryReadFacade -import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUIDOrNull - -/** Other shelves register the complete per-type list, not the visible twenty-card window. */ -@Singleton -class WolfskinSeeAllLists @Inject constructor() { - private val lists = mutableMapOf<String, LibraryGridState>() - - fun open(person: String, key: String, title: String, items: List<LibraryTitle>, navigation: NavigationManager, loadNextPage: (suspend (Int) -> LibraryPage)? = null) { - val identity = "$person/$key" - val grid = LibraryGridState(title, items, catalogue = true, - onTitle = { navigation.navigateTo(libraryTitleDestination(it)) }) - if (loadNextPage != null) grid.pagination = LibraryPagination(grid, loadNextPage) - lists[identity] = grid - navigation.navigateTo(Destination.Wolfskin("seeall/$identity")) - } - - fun get(key: String): LibraryGridState? = lists[key] -} - -/** Debug fixture reads never become a fallback for an unavailable authenticated library. */ -@Composable -fun WolfskinLibraryContent( - context: Context, - route: String, - shell: ShellState, - user: JellyfinUser, - cacheScope: String, - facade: LibraryReadFacade, - navigation: NavigationManager, - seeAll: WolfskinSeeAllLists, - modifier: Modifier = Modifier, -) { - val view = shell.rememberedView(route) - val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) { - value = null - if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { - context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } - } - } - val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } } - val grid = remember(shell.person.key, route, view) { - view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState( - title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), - onTitle = { navigation.navigateTo(libraryTitleDestination(it)) }, - onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) }, - onRecover = { view.library?.let { it.refreshRevision++ } }, - ).also { it.status = LibraryStatus.Loading; view.library = it } - } - if (grid == null) { - ShellPlaceholder("See all list unavailable", modifier) - return - } - LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) { - if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { - val wasLoading = grid.status == LibraryStatus.Loading - if (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab) - val entryRevision = shell.inputRevision - if (fixture != null) { - grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } - grid.status = LibraryStatus.Ready - } else { - val store = withContext(Dispatchers.IO) { snapshots.value } - store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> - val focusedId = grid.focusedTitle?.id - grid.source = snapshot.items - focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } - grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready - grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null - grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None - if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable - if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false - } - } - if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false - } - } - LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view) -} - -val LIBRARY_DESTINATIONS = setOf("movies", "shows", "anime") - -private fun libraryTitleDestination(title: LibraryTitle): Destination = - title.id.toUUIDOrNull()?.let { Destination.MediaItem(it, if (title.type == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) } - ?: Destination.Wolfskin(titleRoute(title.id)) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt deleted file mode 100644 index b85b702f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.justinvisser.wolfskin.tv - -import android.content.Context -import android.content.SharedPreferences -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.content.edit -import androidx.preference.PreferenceManager - -/** The single rollout preference is exposed only in upstream Advanced settings. */ -object WolfskinScreenPreference { - private const val KEY = "wolfskin.screens.enabled" - private lateinit var preferences: SharedPreferences - private var current by mutableStateOf(true) - - fun initialize(context: Context) { - preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) - current = preferences.getBoolean(KEY, true) - } - var enabled: Boolean - get() = current - set(value) { - current = value - preferences.edit { putBoolean(KEY, value) } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt deleted file mode 100644 index 10422c09..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import org.justinvisser.wolfskin.conductarr.CacheEntry -import org.justinvisser.wolfskin.conductarr.FileCacheStore -import org.justinvisser.wolfskin.design.HomeLibrary -import org.justinvisser.wolfskin.design.HomeTitle - -/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */ -@Singleton -class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) { - private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) } - - fun read(scope: String): Pair<HomeLibrary, Long>? { - val entry = store.read(scope, KEY) ?: return null - return try { - val json = JSONObject(entry.body) - val destinations = json.getJSONObject("destinations") - val totals = json.getJSONObject("totals") - HomeLibrary( - decode(json.getJSONArray("resume")), decode(json.getJSONArray("nextUp")), decode(json.getJSONArray("newest")), - destinations.keys().asSequence().associateWith { decode(destinations.getJSONArray(it)) }, - totals.keys().asSequence().associateWith { totals.getInt(it) }, - ) to entry.savedAtMillis - } catch (_: JSONException) { store.remove(scope, KEY); null } - } - - fun write(scope: String, library: HomeLibrary, savedAtMillis: Long) { - val json = JSONObject().put("resume", encode(library.resume)).put("nextUp", encode(library.nextUp)).put("newest", encode(library.newest)) - .put("destinations", JSONObject().apply { library.destinations.forEach { (key, titles) -> put(key, encode(titles)) } }) - .put("totals", JSONObject(library.destinationTotals)) - store.write(scope, KEY, CacheEntry(json.toString(), null, savedAtMillis, savedAtMillis, group = "home")) - } - - private fun encode(titles: List<HomeTitle>): JSONArray = JSONArray().apply { - titles.forEach { title -> put(JSONObject().apply { - put("id", title.id); put("title", title.title); put("type", title.type); put("year", title.year) - put("runtime", title.runtimeSeconds); put("seasons", title.seasons); put("overview", title.overview) - put("poster", title.poster); put("backdrop", title.backdrop); put("posterOnly", title.posterOnly) - put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) - put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) - put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) - }) } - } - - private fun decode(items: JSONArray): List<HomeTitle> = List(items.length()) { index -> - val item = items.getJSONObject(index) - fun string(key: String): String? = if (item.isNull(key)) null else item.getString(key) - fun number(key: String): Int? = if (item.isNull(key)) null else item.getInt(key) - val genres = item.getJSONArray("genres") - HomeTitle( - item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), - string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), - List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), - ) - } - - companion object { private const val KEY = "home:jellyfin" } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt deleted file mode 100644 index 1867ae60..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ /dev/null @@ -1,86 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.services.ImageUrlService -import java.util.UUID -import javax.inject.Inject -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.client.extensions.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.ImageType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.SortOrder -import org.justinvisser.wolfskin.design.HomeLibrary -import org.justinvisser.wolfskin.design.HomeTitle - -/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ -class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { - val audiences get() = libraries.audiences - suspend fun read(userId: UUID): HomeLibrary { - val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items - val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items - val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> - try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } - catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } - } - val resume = resumeItems.map { title(it, series) } - val next = nextItems.map { title(it, series) } - val libraries = api.userViewsApi.getUserViews(userId = userId).content.items - val destinations = linkedMapOf<String, MutableList<HomeTitle>>() - val newest = mutableListOf<HomeTitle>() - val totals = linkedMapOf<String, Int>() - for (library in libraries) { - val key = this.libraries.destination(library.name.orEmpty(), library.collectionType) ?: continue - val response = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.IS_PLAYED, ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.ASCENDING, SortOrder.DESCENDING), limit = 20).content - destinations.getOrPut(key) { mutableListOf() }.addAll(response.items.map { title(it) }) - totals[key] = (totals[key] ?: 0) + response.totalRecordCount - newest.addAll(api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = FIELDS, sortBy = listOf(ItemSortBy.DATE_CREATED), sortOrder = listOf(SortOrder.DESCENDING), limit = 12).content.items.map { title(it) }) - } - return HomeLibrary(resume, next, newest.sortedByDescending { it.addedAt }.take(12), destinations, totals) - } - - suspend fun playableId(userId: UUID, title: HomeTitle): UUID? { - title.id.let { runCatching { UUID.fromString(it) }.getOrNull() }?.let { id -> - val item = api.userLibraryApi.getItem(userId = userId, itemId = id).content - if (item.type == BaseItemKind.SERIES) { - return (api.tvShowsApi.getNextUp(userId = userId, seriesId = id, limit = 1).content.items.firstOrNull() - ?: api.tvShowsApi.getEpisodes(seriesId = id, userId = userId, limit = 1).content.items.firstOrNull()) - ?.takeIf { it.type == BaseItemKind.EPISODE }?.id - } - return item.id.takeIf { item.type == BaseItemKind.MOVIE || item.type == BaseItemKind.EPISODE } - } - val kinds = if (title.isEpisode) listOf(BaseItemKind.EPISODE) else listOf(BaseItemKind.MOVIE) - return api.itemsApi.getItems(userId = userId, recursive = true, searchTerm = title.title, includeItemTypes = kinds, fields = FIELDS, limit = 20).content.items.firstOrNull { - it.name == title.title && (!title.isEpisode || (it.seriesName == title.seriesTitle && it.parentIndexNumber == title.season && it.indexNumber == title.episode)) - }?.id - } - - private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle { - val wrapped = BaseItem(item) - return HomeTitle( - id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, - year = item.productionYear, runtimeSeconds = item.runTimeTicks?.div(TICKS_PER_SECOND)?.toInt(), seasons = item.childCount, - overview = item.overview.orEmpty(), poster = images.getItemImageUrl(wrapped, ImageType.PRIMARY, fillWidth = 256, fillHeight = 384), - backdrop = images.getItemImageUrl(wrapped, ImageType.BACKDROP, fillWidth = 1280, fillHeight = 720), - seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, - resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, - addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, - tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), - ) - } - - companion object { - private const val TICKS_PER_SECOND = 10_000_000L - private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt deleted file mode 100644 index 775235f2..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -import com.github.damontecres.wholphin.ui.nav.Destination -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.ImageType -import org.justinvisser.wolfskin.design.LibraryTitle -import org.justinvisser.wolfskin.design.WolfskinTokens -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.CollectionType -import java.util.UUID -import javax.inject.Inject - -/** SDK reads are confined here so shell and screen state never depend on SDK responses. */ -@javax.inject.Singleton -class LibraryReadFacade @Inject constructor(private val api: ApiClient) { - var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null) - - fun destination(name: String, collection: CollectionType?): String? { - audiences?.let { known -> - val audience = known.firstOrNull { name in it.libraries.orEmpty() } ?: return null - return if (audience.isAnime) "anime" else if (audience.mediaType.name == "MOVIE") "movies" else "shows" - } - return when { - name.contains("anime", ignoreCase = true) -> "anime" - collection == CollectionType.MOVIES -> "movies" - collection == CollectionType.TVSHOWS -> "shows" - else -> null - } - } - suspend fun readDestinations(userId: UUID): Map<String, Destination> { - val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() - return buildMap { - libraries.forEach { library -> - val key = destination(library.name.orEmpty(), library.collectionType) - if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) - } - } - } - - /** Audience metadata names a grouping; only this user's visible titles make Anime available. */ - suspend fun hasAnimeTitles(userId: UUID): Boolean { - val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() - for (library in libraries) { - if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue - val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true, - includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty() - if (titles.isNotEmpty()) return true - } - return false - } - - /** A destination spans every matching accessible library; containers never become titles. */ - fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow { - val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> - destination(library.name.orEmpty(), library.collectionType) == destination - } - val titles = linkedMapOf<UUID, LibraryTitle>() - for (library in libraries) { - var offset = 0 - do { - val response = api.itemsApi.getItems( - userId = userId, parentId = library.id, recursive = true, - includeItemTypes = listOf(if (destination == "movies") BaseItemKind.MOVIE else BaseItemKind.SERIES), - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED), - sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = offset, - limit = WolfskinTokens.Behaviour.libraryChunk.toInt(), - ).content - val chunk = response.items.orEmpty() - for (item in chunk) { - val runtime = item.runTimeTicks?.div(10_000_000) - val position = item.userData?.playbackPositionTicks ?: 0 - titles[item.id] = LibraryTitle( - id = item.id.toString(), title = item.name.orEmpty(), type = if (item.type == BaseItemKind.MOVIE) "movie" else "tv", - year = item.productionYear, runtimeSeconds = runtime, seasons = item.childCount, - poster = item.imageTags?.get(ImageType.PRIMARY)?.let { api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, tag = it) }, - played = item.userData?.played == true, - progress = if ((item.runTimeTicks ?: 0) > 0) (position.toDouble() / item.runTimeTicks!!).toFloat() else 0f, - addedAt = item.dateCreated?.toString().orEmpty(), tmdbId = item.providerIds?.get("Tmdb"), - ) - } - offset += chunk.size - if (chunk.isNotEmpty()) emit(titles.values.toList()) - } while (chunk.isNotEmpty() && offset < response.totalRecordCount) - } - if (titles.isEmpty()) emit(emptyList()) - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt deleted file mode 100644 index 85395ba0..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibrarySnapshotStore.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import java.io.IOException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import org.justinvisser.wolfskin.conductarr.CacheEntry -import org.justinvisser.wolfskin.conductarr.CacheStore -import org.justinvisser.wolfskin.design.LibraryTitle -import org.justinvisser.wolfskin.design.WolfskinTokens - -/** Saved data is published before refresh; an unreachable server never replaces it with an error. */ -data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Long?, val unreachable: Boolean) - -/** Library snapshots share the existing atomic disk store, partitioned by server and person. */ -class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) { - fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow { - val key = "library:$destination" - val saved = try { cache.read(scope, key) } catch (_: IOException) { null } - val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } } - if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false)) - val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong() - if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow - var current: List<LibraryTitle> = emptyList() - try { - load().collect { chunk -> - current = chunk - if (titles == null) emit(LibrarySnapshot(chunk, null, false)) - } - val time = now() - if (titles != null) emit(LibrarySnapshot(current, time, false)) - try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true)) - } - }.flowOn(Dispatchers.IO) - - private fun encode(items: List<LibraryTitle>): String = JSONArray().apply { - items.forEach { title -> put(JSONObject().apply { - put("id", title.id); put("title", title.title); put("type", title.type) - put("year", title.year); put("runtime", title.runtimeSeconds); put("seasons", title.seasons) - put("poster", title.poster); put("played", title.played); put("progress", title.progress) - put("added", title.addedAt); put("tmdb", title.tmdbId) - }) } - }.toString() - - private fun decode(body: String): List<LibraryTitle> { - val array = JSONArray(body) - return List(array.length()) { index -> array.getJSONObject(index).let { item -> - LibraryTitle(id = item.getString("id"), title = item.getString("title"), type = item.getString("type"), - year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtime").takeIf { it > 0 }, - seasons = item.optInt("seasons").takeIf { it > 0 }, poster = item.optString("poster").takeIf { it.isNotEmpty() }, - played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(), addedAt = item.optString("added"), - tmdbId = item.optString("tmdb").takeIf { it.isNotEmpty() }) - } } - } -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt deleted file mode 100644 index 4b3a3a4a..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ /dev/null @@ -1,216 +0,0 @@ -package org.justinvisser.wolfskin.tv.read - -import android.content.Context -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.TrackIndex -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.ImageUrlService -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaStreamType -import org.jellyfin.sdk.model.api.request.GetEpisodesRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import org.json.JSONObject -import org.justinvisser.wolfskin.design.TitleAction -import org.justinvisser.wolfskin.design.TitleConnection -import org.justinvisser.wolfskin.design.TitleItem -import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitlePerson -import org.justinvisser.wolfskin.design.TitleTrack -import org.justinvisser.wolfskin.design.TitleVersion -import java.io.File -import java.util.Locale -import java.util.UUID -import javax.inject.Inject - -data class PersonRead(val person: TitlePerson, val credits: List<TitleItem>, val savedAt: String? = null) - -/** Jellyfin SDK changes are confined to this boundary; title UI sees immutable screen data only. */ -class TitleReadFacade @Inject constructor( - private val api: ApiClient, - private val images: ImageUrlService, - private val playback: ItemPlaybackRepository, - private val repository: ServerRepository, - private val watched: FavoriteWatchManager, - @param:ApplicationContext private val context: Context, -) { - private val json = Json { ignoreUnknownKeys = true } - private val fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.PEOPLE, ItemFields.PARENT_ID) - - /** Resolve through this user's Jellyfin permissions before granting a catalogue card playback. */ - suspend fun findHeld(tmdb: Long, media: String): UUID? { - val response = api.request(org.jellyfin.sdk.api.client.HttpMethod.GET, "/Items", - mapOf("UserId" to repository.currentUser?.id, "Recursive" to true, - "IncludeItemTypes" to if (media == "movie") "Movie" else "Series", - "AnyProviderIdEquals" to "Tmdb.$tmdb", "Fields" to "ProviderIds", "Limit" to 1), emptyMap(), null) - .createContent<org.jellyfin.sdk.model.api.BaseItemDtoQueryResult>() - return response.items?.firstOrNull { row -> - row.type == (if (media == "movie") BaseItemKind.MOVIE else BaseItemKind.SERIES) && - row.providerIds?.entries?.any { it.key.equals("Tmdb", true) && it.value?.toLongOrNull() == tmdb } == true - }?.id - } - - /** A disk copy is decoded before any network read; ownership is isolated by server and user. */ - suspend fun readSaved(id: UUID, preferences: UserPreferences): TitlePageData? = withContext(Dispatchers.IO) { - val file = cacheFile(id) - if (!file.exists()) return@withContext null - try { - val saved = JSONObject(file.readText()) - val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), saved.getString("items")) - val numbers = saved.getJSONArray("seasonNumbers").let { array -> List(array.length()) { array.getInt(it) } } - build(rows, numbers, saved.optInt("selectedSeason"), saved.getString("watch"), saved.optString("target").takeIf { it.isNotEmpty() }, preferences) - .copy(savedAt = savedTime(file), trailerUrl = saved.optString("trailer").takeIf { it.isNotEmpty() }) - } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } catch (_: org.json.JSONException) { null } - } - - /** An episode opens its series with that exact episode as both play and watched target. */ - suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { - val file = cacheFile(id) - val opened = readItem(id) - val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened - val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() - val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { - api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } - } - val selectedNumber = season ?: next?.parentIndexNumber ?: seasons.firstOrNull()?.indexNumber - val selected = seasons.firstOrNull { it.indexNumber == selectedNumber } ?: seasons.firstOrNull() - val episodes = selected?.let { api.tvShowsApi.getEpisodes(GetEpisodesRequest(seriesId = series.id, seasonId = it.id, fields = fields)).content.items.orEmpty() }.orEmpty() - val target = next ?: episodes.firstOrNull { it.type == BaseItemKind.EPISODE } - val rows = (listOf(series, opened) + listOfNotNull(target) + episodes).distinctBy { it.id } - val numbers = seasons.mapNotNull { it.indexNumber } - val result = build(rows, numbers, selected?.indexNumber, opened.id.toString(), target?.id?.toString(), preferences) - val localTrailer = if (result.trailerUrl == null && (series.localTrailerCount ?: 0) > 0) api.userLibraryApi.getLocalTrailers(series.id).content.firstOrNull()?.id?.toString() else null - val trailer = result.trailerUrl ?: localTrailer?.let { "jellyfin:$it" } - val saved = JSONObject().put("items", json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) - .put("seasonNumbers", org.json.JSONArray(numbers)).put("selectedSeason", selected?.indexNumber).put("watch", opened.id.toString()) - .put("target", target?.id?.toString().orEmpty()).put("trailer", trailer.orEmpty()) - if (cacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Title identity changed") - file.parentFile?.mkdirs() - try { file.writeText(saved.toString()) } catch (_: java.io.IOException) { /* Playback remains available when the disk cannot save a copy. */ } - result.copy(trailerUrl = trailer) - } - - /** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */ - private suspend fun readItem(id: UUID): BaseItemDto { - val item = api.userLibraryApi.getItem(id).content - if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item - val metadata = try { - api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id, - fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id } - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } ?: return item - return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people) - } - - /** A failed reachability read leaves navigation on the title instead of opening a dead player. */ - suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } - - /** Track selection uses the same repository consumed by the upstream player. */ - suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { - when (action) { - is TitleAction.Version -> playback.savePlayVersion( - UUID.fromString(action.item.id), - requireNotNull(action.version.id.toUUIDOrNull()) { "Invalid Jellyfin media source identifier" }, - ) - is TitleAction.Track -> { - val id = UUID.fromString(action.item.id) - val item = BaseItem(api.userLibraryApi.getItem(id).content) - var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) } - action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) } - playback.saveTrackSelection(item, choice, if (action.subtitle == -1) TrackIndex.DISABLED else action.subtitle, MediaStreamType.SUBTITLE) - } - is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched) - else -> Unit - } - Unit - } - - /** A person keeps the same server/user-scoped last good copy as a title. */ - suspend fun readPersonSaved(id: UUID): PersonRead? = withContext(Dispatchers.IO) { - val file = personCacheFile(id) - if (!file.exists()) return@withContext null - try { - val rows = json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) - personData(id, rows.first(), rows.drop(1)).copy(savedAt = savedTime(file)) - } catch (_: java.io.IOException) { null } catch (_: IllegalArgumentException) { null } - } - - /** Held performer credits are read continuously; a signed-out TV never falls back to Seerr. */ - suspend fun readPerson(id: UUID, start: Int): PersonRead = withContext(Dispatchers.IO) { - val file = personCacheFile(id) - val previous = if (start > 0 && file.exists()) { - try { json.decodeFromString(ListSerializer(BaseItemDto.serializer()), file.readText()) } - catch (_: java.io.IOException) { emptyList() } catch (_: IllegalArgumentException) { emptyList() } - } else emptyList() - val person = previous.firstOrNull() ?: api.userLibraryApi.getItem(id).content - val credits = api.itemsApi.getItems(GetItemsRequest(personIds = listOf(id), recursive = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), fields = fields, startIndex = start, limit = 200, - sortBy = listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME))).content.items.orEmpty() - val rows = listOf(person) + (previous.drop(1) + credits).distinctBy { it.id } - if (personCacheFile(id) != file) throw kotlinx.coroutines.CancellationException("Performer identity changed") - file.parentFile?.mkdirs() - try { file.writeText(json.encodeToString(ListSerializer(BaseItemDto.serializer()), rows)) } - catch (_: java.io.IOException) { /* The readable grid does not disappear when saving fails. */ } - personData(id, person, credits) - } - - private fun personData(id: UUID, person: BaseItemDto, credits: List<BaseItemDto>): PersonRead = - PersonRead(TitlePerson(id.toString(), person.name.orEmpty(), portrait = images.getItemImageUrl(id, ImageType.PRIMARY, maxWidth = 240, maxHeight = 240), biography = person.overview.orEmpty()), - credits.map { credit -> mapItem(credit).copy(character = credit.people.orEmpty().firstOrNull { it.id == id }?.role) }) - - private suspend fun build(rows: List<BaseItemDto>, seasonNumbers: List<Int>, selectedSeason: Int?, watchId: String, targetId: String?, preferences: UserPreferences): TitlePageData { - val item = rows.first() - val target = rows.firstOrNull { it.id.toString() == targetId } - val chosen = target?.let { playback.getSelectedTracks(it.id, BaseItem(it), preferences) } - val versions = target?.mediaSources.orEmpty().map { source -> - val streams = source.mediaStreams.orEmpty() - TitleVersion(source.id.orEmpty(), source.name ?: source.path ?: source.id.orEmpty(), streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle.orEmpty(), - source.bitrate?.let { String.format(Locale.getDefault(), "%.1f Mbps", it / 1_000_000.0) }.orEmpty(), - source.size?.let { String.format(Locale.getDefault(), "%.1f GB", it / 1_000_000_000.0) }.orEmpty(), source.container.orEmpty().uppercase(), - streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, - streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) - } - return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) }, - rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, - if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1, - item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, - cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> - TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) - }, - episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) }, - connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) - } - - private fun mapItem(item: BaseItemDto): TitleItem = TitleItem( - item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" }, - item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(), - poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) }, - backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) }, - held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, - seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, - tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(), - ) - - private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") - private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") - private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) -} diff --git a/build.gradle.kts b/build.gradle.kts index 4ff28386..f2004532 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.protobuf) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt) apply false - alias(libs.plugins.android.library) apply false } diff --git a/conductarr-client/.gitignore b/conductarr-client/.gitignore deleted file mode 100644 index 84c048a7..00000000 --- a/conductarr-client/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build/ diff --git a/conductarr-client/README.md b/conductarr-client/README.md deleted file mode 100644 index a589b36d..00000000 --- a/conductarr-client/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Conductarr TV client - -A UI-free JVM module for the TV operations in the [approved TV spec](https://pages.justinvisser.org/wolfskin/tv-spec), sections 3 and 8. The Android app supplies the currently selected Jellyfin login through its person-scoped `ConductarrSession`. - -## Pinned contract and generator decision - -`openapi/openapi.json` is the unmodified OpenAPI 3.1.0 document from Conductarr commit `7e8f3f0a1c9a88d67b2a9518c1161db84ade531a`. `openapi/commit.sha` records the source. It was read with `git show <commit>:openapi.json` from the Conductarr clone, not downloaded from a running service. - -The approved fallback is used: checked-in, maintained kotlinx.serialization data classes and a thin typed TV API, rather than build-time generation. - -The OpenAPI Generator **7.26.0** probe used the `kotlin` generator with `serializationLibrary=kotlinx_serialization`, `useCoroutines=true`, `modelMutable=false` and `enumPropertyNaming=UPPERCASE`. Generation completed with its OpenAPI 3.1 beta warning. Nullable arrays were retained. However, the unconstrained JSON schema at `ErrorDetail.value` became `@Contextual Any?`. The generated serializer module has no `Any` adapter. Compiling the generated `ErrorDetail` and decoding `{"value":{"nested":[1,null,true]}}` with kotlinx.serialization produced: - -```text -Serializer for class 'Any' is not found. -``` - -Thus the generator does **not** handle every construct in this pinned document. The fallback uses `JsonElement?` for arbitrary error values. Its regression test covers objects, arrays, numbers, booleans and null. - -`ContractTest` checks: - -- All 22 TV method/path pairs against the pinned paths, including response and mutation body schemas. -- All 70 maintained schemas through their actual serialization descriptors: fields, references, primitive widths, collections, maps, enum wire values and requiredness. Read-only `$schema` metadata is intentionally ignored. Optional scalar properties use nullable Kotlin fields to represent absence; encoding omits them. -- A separately reviewed schema snapshot, `src/test/resources/model-contracts.json`, so changes to formats, limits, defaults or other schema constraints also require explicit review. Models preserve those wire shapes; Conductarr remains responsible for business validation and authority. - -When updating the pin, update the models and reviewed schema snapshot together. Do not regenerate the Seerr client into this module or substitute a running server's schema. - -## API and identity - -Create a `ConductarrClient(baseUrl, jellyfinToken, cache)` for the selected person's Jellyfin session. The caller supplies the token; the client sends exactly `Authorization: MediaBrowser Token="<token>"`. The token must match the pinned contract's 32-hex Jellyfin access-token shape. HTTPS is required except for loopback fake servers. Base URL credentials, query strings and fragments are rejected. A path prefix is preserved. - -`TvRoutes` provides typed calls for operator, profiles, feed, dismissals, catalogue options/discovery/title/cast/related/enrichment/ratings/display/seasons, performers/filmography, activity/readiness, requests and library audiences. Request actors cannot be supplied: only the authenticated operator establishes the actor. The request body's requester selects the beneficiary. - -`client.read(call, trigger)` is a cold `Flow<ReadResult<T>>`: collect it once per screen-level read. It emits saved data before starting network work, then a revalidation result when due. `ReadResult.Failure.saved` retains LKG on transport, HTTP and decoding failure. A 401 returns `NeedsSignIn` **without** fallback, invalidates that client's identity-scoped saved data, and prevents further calls on that instance. Create a new instance after sign-in/person switch. No Seerr fallback exists. - -`client.mutate(call)` returns a typed success or error. It never uses cached receipts, retries or follows redirects. Unknown request outcomes must be checked using `TvRoutes.requests(tmdb, media)`; do not blindly resend. - -The transport owns its OkHttp client rather than accepting arbitrary credential-logging interceptors. Diagnostics expose only `ClientEvent` enum values. Returned errors exclude bodies and exception text. Default timeouts: connection 10 s, read 20 s, whole call 30 s. At most two catalogue reads run concurrently. Close the client when its identity/session is discarded. - -## Saved data and refresh events - -Pass an **app-private, backup-excluded directory** to `FileCacheStore`; one store instance owns a directory in a process. Writes use a synced temporary file and atomic same-filesystem replacement. Corrupt records become misses. `MemoryCacheStore` is the deterministic fake. Data is not encrypted: cached household content belongs in private app storage, never shared/external storage. - -Cache keys partition by normalized server URL, credential digest, endpoint, path parameters and sorted query. Raw credentials are neither stored nor logged. A stale lifetime means refresh eligibility, **not deletion** of LKG. `Saved.savedAtMillis` supplies the Saved HH:MM timestamp; 304 preserves that timestamp while advancing validation time. - -| Operation family | Refresh rule | -|---|---| -| Operator and recommendation profiles | Retain indefinitely; `LAUNCH` refreshes | -| Library audiences | Daily | -| Feed | Server `expires_at`; conditional ETag revalidation | -| Catalogue options | `FOREGROUND` refreshes | -| Discovery pages | 30 minutes per parameterized lane/page | -| Ratings-only read | 6 hours; 300 entries per movie/TV group; 404 means saved absence, failures never overwrite LKG | -| Title, cast, related, enrichment, display, performer, filmography, dismissals, readiness | `OPEN` refreshes | -| Seasons | 6 hours for episode scores; `OPEN` refreshes on title-season focus; retained per season, 300 entries | -| Activity | 30 seconds for caller-driven `POLL`, or `FOREGROUND` | -| Title requests and request mutations | No cache | - -`IF_STALE` observes lifetimes; `FORCE` explicitly revalidates; `CACHE_ONLY` paints without network. The caller schedules launch, foreground, screen-open and activity polling events; the module does not run a poller or own a UI spinner. Title-related calls should be hoisted, not issued by individual cards. ETags are sent when present on any endpoint, regardless of `Cache-Control: no-store`; this is the TV-owned application cache, not an HTTP response cache. - -## Verification - -The root `make test` selects `testDefaultDebugUnitTest` in this JVM module as well as the app. That module alias runs the JUnit contract/MockWebServer suite and the `ClientSmokeKt` executable. The smoke exercises a real loopback socket, disk reopen, ETag 304, and 503 recovery. All tests are local; none contact Conductarr or Jellyfin. - -The build gate is `make test` followed by `make apk-debug`, using the repository's serialized Docker Gradle wrapper. No signed release, installation, ADB, version change or production activation is part of this module. diff --git a/conductarr-client/build.gradle.kts b/conductarr-client/build.gradle.kts deleted file mode 100644 index d4e32aa0..00000000 --- a/conductarr-client/build.gradle.kts +++ /dev/null @@ -1,36 +0,0 @@ -plugins { - `java-library` - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.plugin.serialization) -} - -kotlin { - compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) -} -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - -dependencies { - api(libs.kotlinx.serialization.json) - api(platform(libs.okhttp.bom)) - implementation(libs.okhttp) - api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${libs.versions.kotlinxCoroutinesTest.get()}") - testImplementation(libs.junit) - testImplementation("com.squareup.okhttp3:mockwebserver3") -} - -sourceSets.test { resources.srcDir("openapi") } - -val smoke by tasks.registering(JavaExec::class) { - dependsOn(tasks.testClasses) - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("org.justinvisser.wolfskin.conductarr.ClientSmokeKt") - mustRunAfter(tasks.test) -} - -// The root gate selects this name across all projects, including this JVM module. -tasks.register("testDefaultDebugUnitTest") { - dependsOn(tasks.test, smoke) -} diff --git a/conductarr-client/openapi/commit.sha b/conductarr-client/openapi/commit.sha deleted file mode 100644 index 9944c1d5..00000000 --- a/conductarr-client/openapi/commit.sha +++ /dev/null @@ -1 +0,0 @@ -7e8f3f0a1c9a88d67b2a9518c1161db84ade531a diff --git a/conductarr-client/openapi/openapi.json b/conductarr-client/openapi/openapi.json deleted file mode 100644 index 5486db7c..00000000 --- a/conductarr-client/openapi/openapi.json +++ /dev/null @@ -1,16347 +0,0 @@ -{ - "components": { - "schemas": { - "AbandonInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/AbandonInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "reason": { - "description": "required non-empty audited reason for abandoning the candidate", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "ActingCredit": { - "additionalProperties": false, - "properties": { - "character": { - "type": "string" - }, - "credit_id": { - "type": "string" - }, - "episode_count": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "credit_id", - "character" - ], - "type": "object" - }, - "ActionResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ActionResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "cleared": { - "description": "queue records cleared by a reviewed closeout", - "format": "int64", - "type": "integer" - }, - "detail": { - "type": "string" - }, - "health_state": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "status": { - "description": "ok | blocked | busy | not_eligible | missing | invalid | arr_error", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "ActivityFeed": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ActivityFeed.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/RequestProgress" - }, - "type": [ - "array", - "null" - ] - }, - "requester": { - "description": "Household key the feed is attributed to, or all for the household", - "type": "string" - }, - "sources": { - "items": { - "$ref": "#/components/schemas/EvidenceSource" - }, - "type": [ - "array", - "null" - ] - }, - "truncated": { - "description": "More recent requests exist than the window returned", - "type": "boolean" - } - }, - "required": [ - "requester", - "items", - "truncated", - "sources" - ], - "type": "object" - }, - "Actor": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "requester": { - "description": "The acting operator's own household requester key", - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - } - }, - "required": [ - "name", - "role", - "requester" - ], - "type": "object" - }, - "ArrGateDrop": { - "additionalProperties": false, - "properties": { - "indexer": { - "type": "string" - }, - "rejections": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "release": { - "type": "string" - } - }, - "required": [ - "indexer", - "release", - "rejections" - ], - "type": "object" - }, - "Candidate": { - "additionalProperties": false, - "properties": { - "backdrop_path": { - "description": "TMDB-relative landscape artwork path; absent when none is observed", - "type": "string" - }, - "contributions": { - "items": { - "$ref": "#/components/schemas/SeedContribution" - }, - "type": [ - "array", - "null" - ] - }, - "genre_ids": { - "description": "Observed candidate genre IDs; absence does not prove no genres", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "overview": { - "type": "string" - }, - "poster_path": { - "type": "string" - }, - "score": { - "format": "double", - "type": "number" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "tvdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tvdb_id", - "title", - "overview", - "poster_path", - "year", - "score", - "contributions", - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "CandidateReviewMapping": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/CandidateReviewMapping.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "available": { - "type": "boolean" - }, - "episode": { - "format": "int64", - "type": "integer" - }, - "episode_id": { - "format": "int64", - "type": "integer" - }, - "key": { - "description": "SxxEyy", - "type": "string" - }, - "season": { - "format": "int64", - "type": "integer" - }, - "title": { - "type": "string" - } - }, - "required": [ - "available" - ], - "type": "object" - }, - "CastMember": { - "additionalProperties": false, - "properties": { - "character": { - "type": "string" - }, - "credit_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "order": { - "format": "int64", - "type": "integer" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "profile_url": { - "type": "string" - } - }, - "required": [ - "performer_id", - "name", - "character", - "credit_id", - "order" - ], - "type": "object" - }, - "CreateReplacementHoldInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/CreateReplacementHoldInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "arr": { - "description": "primary Arr instance name", - "minLength": 1, - "type": "string" - }, - "episode_ids": { - "description": "one or more Sonarr episode ids; mutually exclusive with movie_id", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "movie_id": { - "description": "one Radarr movie id; mutually exclusive with episode_ids", - "format": "int64", - "type": "integer" - }, - "reason": { - "description": "why this replacement is being held", - "minLength": 1, - "type": "string" - }, - "ttl_hours": { - "description": "hold duration in hours; defaults to 168", - "format": "int64", - "maximum": 720, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "arr", - "reason" - ], - "type": "object" - }, - "DeleteBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/DeleteBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "result": { - "$ref": "#/components/schemas/DeleteResult", - "description": "the delete outcome (ok | partial | conflict | inconclusive | missing_in_arr | arr_error | not_wired | error) with pruned + mirror counts" - } - }, - "required": [ - "result" - ], - "type": "object" - }, - "DeleteRequest": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/DeleteRequest.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "scope_id": { - "description": "The scope_id of the deletion impact the operator confirmed; a different or unobtainable current scope deletes nothing", - "pattern": "^[0-9a-f]{64}$", - "type": "string" - } - }, - "required": [ - "scope_id" - ], - "type": "object" - }, - "DeleteResult": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "mirrors": { - "format": "int64", - "type": "integer" - }, - "orphans": { - "format": "int64", - "type": "integer" - }, - "pruned": { - "format": "int64", - "type": "integer" - }, - "status": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "status" - ], - "type": "object" - }, - "DeletionAudience": { - "additionalProperties": false, - "properties": { - "bucket": { - "description": "Present only for a layer source", - "type": "string" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "owner": { - "description": "Membership owner label; absent for direct import sources", - "type": "string" - }, - "source": { - "description": "Configured layer:bucket or import:root-folder-basename reference", - "type": "string" - }, - "users": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "source", - "libraries", - "users" - ], - "type": "object" - }, - "DeletionCopy": { - "additionalProperties": false, - "properties": { - "arr_kind": { - "type": "string" - }, - "arr_name": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "path": { - "type": "string" - }, - "present": { - "type": "boolean" - }, - "title": { - "type": "string" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "arr_kind", - "arr_name", - "present", - "id", - "title", - "year", - "path" - ], - "type": "object" - }, - "DeletionImpact": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/DeletionImpact.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "scope": { - "$ref": "#/components/schemas/DeletionScope" - }, - "scope_id": { - "description": "SHA-256 comparison fingerprint, not authorization; fresh observation required before execution", - "type": "string" - }, - "status": { - "enum": [ - "ready", - "missing", - "inconclusive" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "status" - ], - "type": "object" - }, - "DeletionScope": { - "additionalProperties": false, - "properties": { - "audiences": { - "description": "Configured libraries/viewers affected through memberships, layer links or present primary/mirror import paths", - "items": { - "$ref": "#/components/schemas/DeletionAudience" - }, - "type": [ - "array", - "null" - ] - }, - "links": { - "description": "Managed layer symlinks to unlink, including separately labelled orphan collateral; emptied directories are pruned", - "items": { - "$ref": "#/components/schemas/RemovalLink" - }, - "type": [ - "array", - "null" - ] - }, - "memberships": { - "description": "Exact membership buckets to remove; no watched-state erasure", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "mirrors": { - "description": "Every configured rule-derived mirror, including verified absences", - "items": { - "$ref": "#/components/schemas/DeletionCopy" - }, - "type": [ - "array", - "null" - ] - }, - "primary": { - "$ref": "#/components/schemas/DeletionCopy" - }, - "retained_links": { - "description": "Existing layer links retained by the executor but losing their present primary/mirror source content; not unlink effects", - "items": { - "$ref": "#/components/schemas/RemovalLink" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "primary", - "mirrors", - "memberships", - "links", - "retained_links", - "audiences" - ], - "type": "object" - }, - "Detail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Detail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "held_title": { - "$ref": "#/components/schemas/HeldTitle" - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - }, - "runtime": { - "format": "int64", - "type": "integer" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/Season" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - }, - "title": { - "$ref": "#/components/schemas/Title" - } - }, - "required": [ - "state", - "title", - "metadata_state", - "genres", - "seasons", - "metadata", - "observations" - ], - "type": "object" - }, - "Discovery": { - "additionalProperties": false, - "properties": { - "genre_id": { - "format": "int64", - "type": "integer" - }, - "next": { - "description": "Same selected query's next public page, bounded at page 500; absence alone does not prove catalogue exhaustion", - "type": "string" - }, - "region": { - "description": "Comma-separated selected service regions; NL for unfiltered discovery", - "type": "string" - }, - "service_id": { - "format": "int64", - "type": "integer" - }, - "sort": { - "enum": [ - "trending", - "popular", - "new", - "top", - "upcoming" - ], - "type": "string" - }, - "state": { - "description": "Empty is an answered zero-match lane; exhausted is an empty page beyond upstream total_pages; partial cannot prove absence", - "enum": [ - "observed", - "empty", - "partial", - "exhausted" - ], - "type": "string" - } - }, - "required": [ - "genre_id", - "service_id", - "sort", - "region", - "state" - ], - "type": "object" - }, - "DispatchLogEntry": { - "additionalProperties": false, - "properties": { - "arr_kind": { - "type": "string" - }, - "arr_name": { - "type": "string" - }, - "bucket": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "id": { - "format": "int64", - "type": "integer" - }, - "media_type": { - "type": "string" - }, - "mirrored_to": { - "type": "string" - }, - "outcome": { - "type": "string" - }, - "requested_by": { - "type": "string" - }, - "rule": { - "type": "string" - }, - "source": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "ts": { - "type": "string" - } - }, - "required": [ - "id", - "ts", - "tmdb_id", - "media_type", - "outcome" - ], - "type": "object" - }, - "Display": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Display.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state" - ], - "type": "object" - }, - "DownloadsBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/DownloadsBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "provenance": { - "description": "current primary-Arr library items classified by the acquisition source of their current files: torrent, spot, manual, or usenet-streamed. torrent means at least one record still holds the file a torrent handoff imported; once managed usenet imports have replaced every such file, the item reads as that usenet lane and its torrent history stays in the torrent lane's handoff log", - "items": { - "$ref": "#/components/schemas/ProvenanceItem" - }, - "type": [ - "array", - "null" - ] - }, - "rows": { - "description": "one row per physical download, grouped by Arr target and download id", - "items": { - "$ref": "#/components/schemas/Row" - }, - "type": [ - "array", - "null" - ] - }, - "usage": { - "description": "library item counts and cached actual local-dataset bytes by provenance source", - "items": { - "$ref": "#/components/schemas/SourceUsage" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "rows", - "provenance", - "usage" - ], - "type": "object" - }, - "DryRunPlan": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/DryRunPlan.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "is_anime": { - "description": "bucket-routing anime-ness (from the matched rule)", - "type": "boolean" - }, - "matched": { - "description": "false when no arr had a lookup candidate or no rule matched", - "type": "boolean" - }, - "mirrors": { - "description": "rule-derived mirror instances the add fans out to", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "rule": { - "type": "string" - }, - "target": { - "description": "routed primary arr instance name", - "type": "string" - }, - "target_kind": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "matched", - "mirrors", - "is_anime" - ], - "type": "object" - }, - "Enrichment": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Enrichment.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - }, - "runtime": { - "format": "int64", - "type": "integer" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/Season" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - }, - "title": { - "$ref": "#/components/schemas/Title" - } - }, - "required": [ - "state", - "title", - "metadata_state", - "genres", - "seasons", - "metadata", - "observations", - "ratings" - ], - "type": "object" - }, - "Episode": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "name": { - "type": "string" - }, - "number": { - "format": "int64", - "type": "integer" - }, - "overview": { - "type": "string" - }, - "recorded": { - "enum": [ - "recorded", - "missing", - "unknown" - ], - "type": "string" - }, - "vote_average": { - "description": "Provider episode score on a 0–10 scale; absence is not zero.", - "format": "double", - "type": "number" - }, - "vote_count": { - "description": "Provider episode vote count; explicit zero is retained.", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "number", - "name", - "overview", - "recorded" - ], - "type": "object" - }, - "EpisodeFileChip": { - "additionalProperties": false, - "properties": { - "quality": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "size_bytes" - ], - "type": "object" - }, - "EpisodeOption": { - "additionalProperties": false, - "properties": { - "absolute_episode": { - "format": "int64", - "type": "integer" - }, - "air_date": { - "type": "string" - }, - "episode": { - "format": "int64", - "type": "integer" - }, - "episode_id": { - "format": "int64", - "type": "integer" - }, - "has_file": { - "type": "boolean" - }, - "in_payload": { - "type": "boolean" - }, - "key": { - "description": "SxxEyy", - "type": "string" - }, - "monitored": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "season": { - "format": "int64", - "type": "integer" - }, - "selectable": { - "type": "boolean" - }, - "title": { - "type": "string" - } - }, - "required": [ - "episode_id", - "season", - "episode", - "key", - "monitored", - "has_file", - "in_payload", - "selectable" - ], - "type": "object" - }, - "EpisodeRow": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "episode": { - "format": "int64", - "type": "integer" - }, - "file": { - "$ref": "#/components/schemas/EpisodeFileChip" - }, - "has_file": { - "type": "boolean" - }, - "key": { - "description": "SxxEyy", - "type": "string" - }, - "monitored": { - "type": "boolean" - }, - "queue_state": { - "description": "Arr queue status for this exact episode; omitted when no matching queue row was observed", - "type": "string" - }, - "runtime_minutes": { - "description": "Sonarr episode runtime in minutes; omitted when Sonarr reports none", - "format": "int64", - "type": "integer" - }, - "season": { - "format": "int64", - "type": "integer" - }, - "title": { - "type": "string" - }, - "watched": { - "additionalProperties": { - "type": "boolean" - }, - "type": "object" - } - }, - "required": [ - "season", - "episode", - "key", - "monitored", - "has_file", - "watched" - ], - "type": "object" - }, - "EpisodeView": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/EpisodeView.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/SeasonGroup" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "users": { - "description": "jellyfin usernames whose sees: matrix covers this item (watch-chip columns)", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "tmdb_id", - "title", - "owners", - "users", - "seasons" - ], - "type": "object" - }, - "ErrorDetail": { - "additionalProperties": false, - "properties": { - "location": { - "description": "Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'", - "type": "string" - }, - "message": { - "description": "Error message text", - "type": "string" - }, - "value": { - "description": "The value at the given location" - } - }, - "type": "object" - }, - "ErrorModel": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ErrorModel.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "description": "A human-readable explanation specific to this occurrence of the problem.", - "examples": [ - "Property foo is required but is missing." - ], - "type": "string" - }, - "errors": { - "description": "Optional list of individual error details", - "items": { - "$ref": "#/components/schemas/ErrorDetail" - }, - "type": [ - "array", - "null" - ] - }, - "instance": { - "description": "A URI reference that identifies the specific occurrence of the problem.", - "examples": [ - "https://example.com/error-log/abc123" - ], - "format": "uri", - "type": "string" - }, - "status": { - "description": "HTTP status code", - "examples": [ - 400 - ], - "format": "int64", - "type": "integer" - }, - "title": { - "description": "A short, human-readable summary of the problem type. This value should not change between occurrences of the error.", - "examples": [ - "Bad Request" - ], - "type": "string" - }, - "type": { - "default": "about:blank", - "description": "A URI reference to human-readable documentation for the error.", - "examples": [ - "https://example.com/errors/example" - ], - "format": "uri", - "type": "string" - } - }, - "type": "object" - }, - "EvidenceSource": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "name": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "name", - "state" - ], - "type": "object" - }, - "FileDetail": { - "additionalProperties": false, - "properties": { - "audio": { - "type": "string" - }, - "container": { - "type": "string" - }, - "quality": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - }, - "video": { - "type": "string" - } - }, - "required": [ - "size_bytes" - ], - "type": "object" - }, - "FillGapsResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/FillGapsResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "searched_count": { - "format": "int64", - "type": "integer" - }, - "status": { - "description": "ok | busy | missing_in_arr | arr_error", - "type": "string" - } - }, - "required": [ - "status", - "searched_count" - ], - "type": "object" - }, - "FilmographyPage": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/FilmographyPage.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "completeness": { - "description": "Seerr filters adult credits and excludes crew here; not an exhaustive career", - "enum": [ - "returned-non-adult-cast" - ], - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/FilmographyTitle" - }, - "type": [ - "array", - "null" - ] - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "page": { - "format": "int64", - "type": "integer" - }, - "page_size": { - "format": "int64", - "type": "integer" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - }, - "source_paging": { - "enum": [ - "unpaged" - ], - "type": "string" - }, - "total_pages": { - "description": "Native pages of grouped typed titles; absent when source is unanswered", - "format": "int64", - "type": "integer" - }, - "total_results": { - "description": "Unique typed titles in this returned source set; absent when source is unanswered", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "performer_id", - "metadata_state", - "source", - "source_paging", - "completeness", - "page", - "page_size", - "items", - "observations" - ], - "type": "object" - }, - "FilmographyTitle": { - "additionalProperties": false, - "properties": { - "credits": { - "items": { - "$ref": "#/components/schemas/ActingCredit" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "$ref": "#/components/schemas/Title" - }, - "users": { - "description": "Configured viewers when visibility_state is configured; otherwise unknown", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "visibility_state": { - "description": "Configured library audience only, never observed Jellyfin visibility or playback", - "enum": [ - "configured", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "title", - "credits", - "visibility_state", - "users" - ], - "type": "object" - }, - "Finding": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "message": { - "type": "string" - }, - "severity": { - "description": "Raw Arr HealthCheckResult; informational update warnings remain warning", - "enum": [ - "ok", - "notice", - "warning", - "error" - ], - "type": "string" - }, - "source": { - "type": "string" - }, - "state": { - "description": "Health impact, independent of the raw severity", - "enum": [ - "healthy", - "degraded", - "unhealthy" - ], - "type": "string" - }, - "wiki_url": { - "type": "string" - } - }, - "required": [ - "source", - "severity", - "message", - "state" - ], - "type": "object" - }, - "Genre": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "HealthDetail": { - "additionalProperties": false, - "properties": { - "failed": { - "type": "boolean" - }, - "has_file": { - "type": "boolean" - }, - "in_queue": { - "type": "boolean" - }, - "missing": { - "type": "boolean" - } - }, - "required": [ - "has_file", - "in_queue", - "failed", - "missing" - ], - "type": "object" - }, - "HealthzBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/HealthzBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "config": { - "$ref": "#/components/schemas/HealthzConfig", - "description": "Provenance of the config bytes this process parsed" - }, - "features": { - "$ref": "#/components/schemas/HealthzFeatures", - "description": "Config gates the cockpit shows or hides surfaces on" - }, - "modules": { - "additionalProperties": { - "$ref": "#/components/schemas/HealthzModule" - }, - "description": "Per-module status from the registry", - "type": "object" - }, - "state": { - "description": "Aggregate state; a disabled module is excluded from it", - "enum": [ - "starting", - "healthy", - "degraded", - "unhealthy", - "stopped" - ], - "type": "string" - } - }, - "required": [ - "state", - "modules", - "features" - ], - "type": "object" - }, - "HealthzConfig": { - "additionalProperties": false, - "properties": { - "path": { - "description": "Config file the process loaded", - "type": "string" - }, - "sha256": { - "description": "Hex sha256 of the raw config bytes; compare with a host-side sha256sum", - "type": "string" - }, - "size": { - "description": "Byte length of those config bytes", - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, - "HealthzFeatures": { - "additionalProperties": false, - "properties": { - "torrent_fallback": { - "description": "features.torrent_fallback: the torrent lane and its Torrent tab", - "type": "boolean" - } - }, - "required": [ - "torrent_fallback" - ], - "type": "object" - }, - "HealthzModule": { - "additionalProperties": false, - "properties": { - "checked_at": { - "description": "Cached check time; for Arr health this is the last valid sample, omitted before the first valid sample", - "format": "date-time", - "type": "string" - }, - "detail": { - "description": "Human-readable context for non-healthy states", - "type": "string" - }, - "findings": { - "description": "Cached application diagnostics, preserving raw severity separately from health impact", - "items": { - "$ref": "#/components/schemas/Finding" - }, - "type": [ - "array", - "null" - ] - }, - "kind": { - "description": "process | glue; empty when the restart surface is not wired (spec emission)", - "type": "string" - }, - "observation": { - "$ref": "#/components/schemas/Observation", - "description": "Application observation freshness and latest attempt failure; omitted for modules without this contract" - }, - "state": { - "description": "Module state; disabled means a config gate turned this subsystem off", - "enum": [ - "starting", - "healthy", - "degraded", - "unhealthy", - "stopped", - "disabled" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "type": "object" - }, - "HeldTitle": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/HeldTitle.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "$ref": "#/components/schemas/ItemDetail" - }, - "episodes": { - "$ref": "#/components/schemas/EpisodeView" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial" - ], - "type": "string" - }, - "versions": { - "items": { - "$ref": "#/components/schemas/TitleVersion" - }, - "type": [ - "array", - "null" - ] - }, - "watched": { - "additionalProperties": { - "type": "boolean" - }, - "type": "object" - } - }, - "required": [ - "state", - "detail", - "libraries", - "versions", - "observations", - "watched" - ], - "type": "object" - }, - "HistoryReport": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/HistoryReport.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "from": { - "format": "date-time", - "type": "string" - }, - "modules": { - "items": { - "$ref": "#/components/schemas/ModuleHistory" - }, - "type": [ - "array", - "null" - ] - }, - "samples": { - "format": "int64", - "type": "integer" - }, - "to": { - "format": "date-time", - "type": "string" - }, - "window": { - "type": "string" - } - }, - "required": [ - "window", - "from", - "to", - "samples", - "modules" - ], - "type": "object" - }, - "HoldingStats": { - "additionalProperties": false, - "properties": { - "missing": { - "format": "int64", - "type": "integer" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "size_bytes", - "missing" - ], - "type": "object" - }, - "Item": { - "additionalProperties": false, - "properties": { - "arr_kind": { - "type": "string" - }, - "arr_name": { - "type": "string" - }, - "is_anime": { - "type": "boolean" - }, - "media_type": { - "type": "string" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "poster_ref": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "owners", - "is_anime", - "arr_kind", - "arr_name" - ], - "type": "object" - }, - "ItemDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ItemDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "backdrop_ref": { - "description": "Public provider landscape artwork from the holding Arr", - "type": "string" - }, - "file": { - "$ref": "#/components/schemas/FileDetail" - }, - "health": { - "$ref": "#/components/schemas/HealthDetail" - }, - "is_anime": { - "type": "boolean" - }, - "media_type": { - "type": "string" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "poster_ref": { - "description": "Public provider poster artwork from the holding Arr", - "type": "string" - }, - "runtime": { - "description": "minutes", - "format": "int64", - "type": "integer" - }, - "source": { - "$ref": "#/components/schemas/SourceDetail" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "users": { - "description": "jellyfin usernames whose sees: matrix covers this item (movie watch-chip columns)", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "owners", - "is_anime", - "source", - "health", - "users" - ], - "type": "object" - }, - "ItemsBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ItemsBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "counts": { - "description": "owner-rail counts including Unassigned", - "items": { - "$ref": "#/components/schemas/OwnerCount" - }, - "type": [ - "array", - "null" - ] - }, - "items": { - "description": "poster-grid entries: title/year/poster ref/owners/anime badge", - "items": { - "$ref": "#/components/schemas/Item" - }, - "type": [ - "array", - "null" - ] - }, - "library": { - "$ref": "#/components/schemas/RailStats", - "description": "owner-rail Library stats: size/last import/queue/missing/failed" - }, - "media_type": { - "type": "string" - } - }, - "required": [ - "media_type", - "items", - "counts", - "library" - ], - "type": "object" - }, - "LayerVisibilityActionOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LayerVisibilityActionOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "action": { - "$ref": "#/components/schemas/LayerVisibilityActionView" - }, - "events": { - "items": { - "$ref": "#/components/schemas/LayerVisibilityEventView" - }, - "type": [ - "array", - "null" - ] - }, - "observation": { - "$ref": "#/components/schemas/LayerVisibilityObservationView" - } - }, - "required": [ - "action", - "events" - ], - "type": "object" - }, - "LayerVisibilityActionView": { - "additionalProperties": false, - "properties": { - "action_id": { - "description": "durable layer action identity; the approval names this", - "type": "string" - }, - "bucket": { - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "detail": { - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "operation": { - "enum": [ - "MaterializeAndRefresh" - ], - "type": "string" - }, - "stage": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "updated_at": { - "format": "date-time", - "type": "string" - }, - "version": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "action_id", - "tmdb_id", - "media_type", - "bucket", - "operation", - "stage", - "detail", - "version", - "created_at", - "updated_at" - ], - "type": "object" - }, - "LayerVisibilityActionsOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LayerVisibilityActionsOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/LayerVisibilityActionView" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "LayerVisibilityApprovalView": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LayerVisibilityApprovalView.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "action_id": { - "type": "string" - }, - "bucket": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "stage": { - "type": "string" - }, - "updated_at": { - "format": "date-time", - "type": "string" - }, - "version": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "action_id", - "bucket", - "stage", - "detail", - "version", - "updated_at" - ], - "type": "object" - }, - "LayerVisibilityEventView": { - "additionalProperties": false, - "properties": { - "created_at": { - "format": "date-time", - "type": "string" - }, - "from_stage": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "to_stage": { - "type": "string" - } - }, - "required": [ - "from_stage", - "to_stage", - "reason", - "created_at" - ], - "type": "object" - }, - "LayerVisibilityExpectedItemView": { - "additionalProperties": false, - "properties": { - "key": { - "description": "movie sentinel or SxxEyy logical identity", - "type": "string" - }, - "path": { - "description": "expected owned-layer media-source path", - "type": "string" - }, - "provider": { - "description": "provider episode identity when available", - "type": "string" - } - }, - "required": [ - "key", - "path" - ], - "type": "object" - }, - "LayerVisibilityObservationView": { - "additionalProperties": false, - "properties": { - "bucket": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "expected": { - "items": { - "$ref": "#/components/schemas/LayerVisibilityExpectedItemView" - }, - "type": [ - "array", - "null" - ] - }, - "first_drift_at": { - "format": "date-time", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "observed_at": { - "format": "date-time", - "type": "string" - }, - "status": { - "enum": [ - "converged", - "drift", - "scanner_pending", - "unknown", - "no_files", - "unreleased", - "needs_operator" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "bucket", - "status", - "detail", - "observed_at", - "expected" - ], - "type": "object" - }, - "LayerVisibilityObservationsOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LayerVisibilityObservationsOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/LayerVisibilityObservationView" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "LegacyMovieEntry": { - "additionalProperties": false, - "properties": { - "bucket": { - "type": "string" - }, - "entry_path": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "bucket", - "entry_path" - ], - "type": "object" - }, - "LibraryActivity": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LibraryActivity.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "failed": { - "description": "Titles with a recent failed grab across sources whose history answered", - "format": "int64", - "type": "integer" - }, - "history_state": { - "description": "Whether every source answered its import history; partial means failed and last_import are lower bounds", - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - }, - "last_import": { - "description": "Newest import timestamp across sources whose history answered", - "type": "string" - }, - "media_type": { - "type": "string" - }, - "queue": { - "description": "Active downloads across sources whose queue answered; never a stand-in for an unanswered queue", - "format": "int64", - "type": "integer" - }, - "queue_state": { - "description": "Whether every source answered its download queue; partial means queue is a lower bound", - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - }, - "sources": { - "description": "Per-source answers; detail names the unanswered read", - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "description": "Weakest of queue_state and history_state", - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state", - "queue_state", - "history_state", - "media_type", - "queue", - "failed", - "sources" - ], - "type": "object" - }, - "LibraryAudience": { - "additionalProperties": false, - "properties": { - "is_anime": { - "type": "boolean" - }, - "libraries": { - "description": "Enabled configured household libraries covering this membership scope", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "owner": { - "description": "Named household membership used by the existing add, move and remove interface", - "type": "string" - }, - "users": { - "description": "Enabled Jellyfin usernames whose configured sees covers this scope, not watch-write authority", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "owner", - "media_type", - "is_anime", - "libraries", - "users" - ], - "type": "object" - }, - "LibraryAudiencesBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LibraryAudiencesBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "audiences": { - "items": { - "$ref": "#/components/schemas/LibraryAudience" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "audiences" - ], - "type": "object" - }, - "LibraryCoverage": { - "additionalProperties": false, - "properties": { - "complete": { - "description": "All active predicates classified and collection sources observed; not metadata freshness or playback proof", - "type": "boolean" - }, - "matched": { - "format": "int64", - "type": "integer" - }, - "metadata_observed": { - "description": "Titles with both latest genre and provider outcomes observed, not a freshness guarantee", - "format": "int64", - "type": "integer" - }, - "total": { - "format": "int64", - "type": "integer" - }, - "unresolved": { - "format": "int64", - "type": "integer" - }, - "watched": { - "description": "Watched source observation; absent/conflicting per-title flags remain unknown even after a successful collection read", - "enum": [ - "not_requested", - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "total", - "matched", - "unresolved", - "metadata_observed", - "complete", - "watched" - ], - "type": "object" - }, - "LibraryFact": { - "additionalProperties": false, - "properties": { - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - }, - "watched": { - "enum": [ - "watched", - "unwatched", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "metadata", - "watched" - ], - "type": "object" - }, - "LibraryItem": { - "additionalProperties": false, - "properties": { - "added_at": { - "description": "When the title joined its holding Arr; not an import or playback timestamp", - "type": "string" - }, - "arr_kind": { - "type": "string" - }, - "arr_name": { - "type": "string" - }, - "backdrop_ref": { - "description": "Remote landscape artwork from the holding Arr", - "type": "string" - }, - "is_anime": { - "type": "boolean" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "type": "string" - }, - "missing": { - "type": "boolean" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "poster_ref": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "users": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "libraries", - "users", - "missing", - "tmdb_id", - "media_type", - "title", - "owners", - "is_anime", - "arr_kind", - "arr_name" - ], - "type": "object" - }, - "LibraryObservation": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "observed_at": { - "format": "date-time", - "type": "string" - }, - "source": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "source", - "state" - ], - "type": "object" - }, - "LibraryResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LibraryResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "counts": { - "items": { - "$ref": "#/components/schemas/OwnerCount" - }, - "type": [ - "array", - "null" - ] - }, - "coverage": { - "$ref": "#/components/schemas/LibraryCoverage" - }, - "facts": { - "items": { - "$ref": "#/components/schemas/LibraryFact" - }, - "type": [ - "array", - "null" - ] - }, - "items": { - "items": { - "$ref": "#/components/schemas/LibraryItem" - }, - "type": [ - "array", - "null" - ] - }, - "library": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "observation_age_ms": { - "description": "Age of observed_at when this response was built", - "format": "int64", - "type": "integer" - }, - "observed_at": { - "description": "Oldest answering source read behind this collection; may predate the request by the shared-acquisition window", - "format": "date-time", - "type": "string" - }, - "query": { - "type": "string" - }, - "snapshot": { - "type": "string" - }, - "sources": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - }, - "stats": { - "$ref": "#/components/schemas/HoldingStats" - }, - "unresolved": { - "items": { - "$ref": "#/components/schemas/LibraryItem" - }, - "type": [ - "array", - "null" - ] - }, - "watch_checked_at": { - "description": "Successful watched title-collection observation time; omitted for unrequested or unavailable reads", - "format": "date-time", - "type": "string" - } - }, - "required": [ - "snapshot", - "observed_at", - "observation_age_ms", - "facts", - "unresolved", - "coverage", - "state", - "library", - "media_type", - "query", - "items", - "counts", - "sources", - "stats" - ], - "type": "object" - }, - "MediaHealthBreakerHoldInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthBreakerHoldInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "reason": { - "description": "REQUIRED non-empty reason for suspending detection", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "MediaHealthBreakerResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthBreakerResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "healthy_epochs": { - "description": "clean control epochs credited so far; two close the breaker", - "format": "int64", - "type": "integer" - }, - "held": { - "type": "boolean" - }, - "held_at": { - "type": "string" - }, - "held_by": { - "type": "string" - }, - "hold_reason": { - "type": "string" - }, - "open": { - "type": "boolean" - }, - "reason": { - "description": "the detector fault that opened the breaker, if any", - "type": "string" - } - }, - "required": [ - "open", - "held", - "healthy_epochs", - "detail" - ], - "type": "object" - }, - "MediaHealthConsumerView": { - "additionalProperties": false, - "properties": { - "arr": { - "description": "kind/name of the holding arr", - "type": "string" - }, - "episode_key": { - "description": "SxxEyy; \"-\" for a movie", - "type": "string" - }, - "import_path": { - "type": "string" - }, - "imported_at": { - "type": "string" - }, - "record_type": { - "description": "episode | movie", - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "arr", - "record_type", - "tmdb_id" - ], - "type": "object" - }, - "MediaHealthDecisionInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthDecisionInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "reason": { - "description": "audited operator note; REQUIRED to reject", - "type": "string" - } - }, - "type": "object" - }, - "MediaHealthDecisionResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthDecisionResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "state": { - "description": "the proposal status after the decision", - "type": "string" - }, - "status": { - "description": "ok | not_eligible | missing | invalid", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "MediaHealthDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "history": { - "items": { - "$ref": "#/components/schemas/MediaHealthRepairEvent" - }, - "type": [ - "array", - "null" - ] - }, - "proposal": { - "$ref": "#/components/schemas/MediaHealthProposalView" - } - }, - "required": [ - "proposal", - "history" - ], - "type": "object" - }, - "MediaHealthProbeInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthProbeInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "blob_id": { - "description": "blob id to probe", - "type": "string" - }, - "media_type": { - "description": "Required with tmdb_id for non-admin probes", - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "reason": { - "description": "why this blob needs a fresh probe", - "minLength": 1, - "type": "string" - }, - "tmdb_id": { - "description": "Required for non-admin probes: exact title with retained evidence for this blob", - "format": "int64", - "type": "integer" - }, - "webdav_path": { - "description": "WebDAV path to resolve to a blob", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "MediaHealthProbeResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthProbeResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "blob_id": { - "type": "string" - }, - "requested_at": { - "type": "string" - } - }, - "required": [ - "blob_id", - "requested_at" - ], - "type": "object" - }, - "MediaHealthProposalView": { - "additionalProperties": false, - "properties": { - "blob_id": { - "type": "string" - }, - "confirmed_at": { - "type": "string" - }, - "consumers": { - "items": { - "$ref": "#/components/schemas/MediaHealthConsumerView" - }, - "type": [ - "array", - "null" - ] - }, - "fault_kind": { - "description": "dead_blob", - "type": "string" - }, - "group": { - "description": "review | repairing | closed", - "type": "string" - }, - "media_type": { - "description": "tv | movie", - "type": "string" - }, - "proposal_id": { - "format": "int64", - "type": "integer" - }, - "repair_note": { - "type": "string" - }, - "repair_stage": { - "type": "string" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - }, - "status": { - "description": "the proposal status grammar", - "type": "string" - }, - "summary": { - "description": "what approving this repair will do", - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "webdav_path": { - "type": "string" - } - }, - "required": [ - "proposal_id", - "group", - "status", - "fault_kind", - "blob_id", - "confirmed_at", - "media_type", - "summary", - "consumers" - ], - "type": "object" - }, - "MediaHealthRepairEvent": { - "additionalProperties": false, - "properties": { - "at": { - "type": "string" - }, - "from_stage": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "to_stage": { - "type": "string" - } - }, - "required": [ - "to_stage", - "at" - ], - "type": "object" - }, - "MediaHealthReviewResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MediaHealthReviewResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "breaker_open": { - "type": "boolean" - }, - "breaker_reason": { - "type": "string" - }, - "group": { - "type": "string" - }, - "proposals": { - "items": { - "$ref": "#/components/schemas/MediaHealthProposalView" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "breaker_open", - "proposals" - ], - "type": "object" - }, - "MembershipChange": { - "additionalProperties": false, - "properties": { - "action": { - "enum": [ - "move", - "add", - "remove" - ], - "type": "string" - }, - "actor": { - "description": "Server-authenticated subject that submitted this change", - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/MembershipItem" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - } - }, - "required": [ - "action", - "items" - ], - "type": "object" - }, - "MembershipInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MembershipInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "action": { - "enum": [ - "move", - "add", - "remove" - ], - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/MembershipItem" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - } - }, - "required": [ - "action", - "items" - ], - "type": "object" - }, - "MembershipItem": { - "additionalProperties": false, - "properties": { - "is_anime": { - "type": "boolean" - }, - "media_type": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "is_anime" - ], - "type": "object" - }, - "MembershipJob": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MembershipJob.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "change": { - "$ref": "#/components/schemas/MembershipChange" - }, - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "results": { - "items": { - "$ref": "#/components/schemas/MembershipResult" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "pending", - "running", - "completed", - "failed" - ], - "type": "string" - } - }, - "required": [ - "id", - "state", - "change", - "results" - ], - "type": "object" - }, - "MembershipJobsBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MembershipJobsBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "jobs": { - "items": { - "$ref": "#/components/schemas/MembershipJob" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "jobs" - ], - "type": "object" - }, - "MembershipResult": { - "additionalProperties": false, - "properties": { - "action": { - "type": "string" - }, - "bucket": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "source": { - "type": "string" - }, - "status": { - "type": "string" - }, - "target": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "watch_items": { - "description": "durable rows captured; may also be restored for destination-visible users", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "action", - "source", - "target", - "tmdb_id", - "media_type", - "status" - ], - "type": "object" - }, - "MissingItem": { - "additionalProperties": false, - "properties": { - "check_error": { - "description": "an arr lookup errored — membership uncertain, not pruned", - "type": "boolean" - }, - "media_type": { - "type": "string" - }, - "missing_in": { - "description": "arr instance names the item is absent from", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "rule": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "missing_in" - ], - "type": "object" - }, - "MissingOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MissingOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/MissingItem" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "ModuleHistory": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "series": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "transitions": { - "format": "int64", - "type": "integer" - }, - "uptime": { - "format": "double", - "type": "number" - } - }, - "required": [ - "name", - "series", - "uptime", - "transitions" - ], - "type": "object" - }, - "MovieCutoverBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MovieCutoverBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "result": { - "$ref": "#/components/schemas/MovieCutoverResult", - "description": "the cutover outcome, recovery paths, snapshot count and primary path plan" - } - }, - "required": [ - "result" - ], - "type": "object" - }, - "MovieCutoverCandidate": { - "additionalProperties": false, - "properties": { - "buckets": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "entries": { - "items": { - "$ref": "#/components/schemas/LegacyMovieEntry" - }, - "type": [ - "array", - "null" - ] - }, - "recovery_cutover_id": { - "format": "int64", - "type": "integer" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "unresolved_paths": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "tmdb_id", - "buckets", - "entries" - ], - "type": "object" - }, - "MovieCutoverEvidence": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MovieCutoverEvidence.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "entries": { - "items": { - "$ref": "#/components/schemas/LegacyMovieEntry" - }, - "type": [ - "array", - "null" - ] - }, - "latest": { - "$ref": "#/components/schemas/MovieCutoverResult" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "entries" - ], - "type": "object" - }, - "MovieCutoverPendingBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/MovieCutoverPendingBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "candidates": { - "description": "legacy entries and unfinished recovery cutovers grouped by movie", - "items": { - "$ref": "#/components/schemas/MovieCutoverCandidate" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "candidates" - ], - "type": "object" - }, - "MovieCutoverResult": { - "additionalProperties": false, - "properties": { - "cutover_id": { - "format": "int64", - "type": "integer" - }, - "detail": { - "type": "string" - }, - "entries": { - "items": { - "$ref": "#/components/schemas/LegacyMovieEntry" - }, - "type": [ - "array", - "null" - ] - }, - "primary_moves": { - "items": { - "$ref": "#/components/schemas/PrimaryMove" - }, - "type": [ - "array", - "null" - ] - }, - "status": { - "type": "string" - }, - "step": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "unresolved_paths": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "watch_items": { - "format": "int64", - "type": "integer" - }, - "watch_snapshot": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "cutover_id", - "tmdb_id", - "status", - "step", - "watch_items", - "watch_snapshot" - ], - "type": "object" - }, - "NotifyLogEntry": { - "additionalProperties": false, - "properties": { - "event": { - "description": "media_arrived | media_stalled | media_regrab | media_import_issue", - "type": "string" - }, - "hint": { - "description": "context recorded at send time (stall hint, or issue state + sanitized reason folded with arr/episodes/age)", - "type": "string" - }, - "media_type": { - "type": "string" - }, - "owner": { - "description": "the bucket owner (requester) at send time", - "type": "string" - }, - "sent_at": { - "type": "string" - }, - "stage": { - "description": "0 arrived; 1 first-stall; 2 incomplete-followup; regrab attempt / issue step otherwise", - "format": "int64", - "type": "integer" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "event", - "stage", - "sent_at" - ], - "type": "object" - }, - "NotifyRecentOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/NotifyRecentOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "entries": { - "items": { - "$ref": "#/components/schemas/NotifyLogEntry" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "Observation": { - "additionalProperties": false, - "properties": { - "attempted_at": { - "description": "Completion time of the latest observation attempt, successful or failed", - "format": "date-time", - "type": "string" - }, - "error": { - "description": "Latest observation failure; retained findings are not a new successful observation", - "type": "string" - }, - "freshness": { - "description": "Unknown before a valid sample; stale only after the freshness threshold", - "enum": [ - "unknown", - "fresh", - "stale" - ], - "type": "string" - } - }, - "required": [ - "freshness" - ], - "type": "object" - }, - "OpenLink": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "missing", - "unavailable", - "unconfigured", - "not_permitted" - ], - "type": "string" - }, - "url": { - "description": "The viewer's own Jellyfin item page; the person signs in to Jellyfin as themself", - "type": "string" - } - }, - "required": [ - "state" - ], - "type": "object" - }, - "Operator": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Operator.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "capabilities": { - "$ref": "#/components/schemas/OperatorCapabilities" - }, - "name": { - "type": "string" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - }, - "subject": { - "type": "string" - }, - "watch_user": { - "type": "string" - } - }, - "required": [ - "subject", - "name", - "role", - "requester", - "watch_user", - "recommendation_profile", - "capabilities" - ], - "type": "object" - }, - "OperatorCapabilities": { - "additionalProperties": false, - "properties": { - "administer": { - "type": "boolean" - }, - "manage_library": { - "type": "boolean" - }, - "manage_memberships": { - "type": "boolean" - }, - "recommendation_profiles": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "request_media": { - "type": "boolean" - }, - "requesters": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "watch_users": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "administer", - "manage_library", - "manage_memberships", - "request_media", - "watch_users", - "recommendation_profiles", - "requesters" - ], - "type": "object" - }, - "Option": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "logo_url": { - "description": "Validated TMDB CDN logo for a watch provider; absent for genres and when upstream has no safe logo", - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "Options": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Options.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "$ref": "#/components/schemas/Option" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "region": { - "description": "Comma-separated configured catalogue regions", - "type": "string" - }, - "services": { - "description": "Curated streaming providers observed in their configured regions, not title availability or playback evidence", - "items": { - "$ref": "#/components/schemas/Option" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state", - "media_type", - "region", - "genres", - "services", - "observations" - ], - "type": "object" - }, - "OwnerCount": { - "additionalProperties": false, - "properties": { - "count": { - "format": "int64", - "type": "integer" - }, - "owner": { - "type": "string" - } - }, - "required": [ - "owner", - "count" - ], - "type": "object" - }, - "Page": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Page.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "discovery": { - "$ref": "#/components/schemas/Discovery", - "description": "General discovery context and upstream page observation, independent of local holding freshness" - }, - "items": { - "items": { - "$ref": "#/components/schemas/Title" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "page": { - "format": "int64", - "type": "integer" - }, - "query": { - "type": "string" - }, - "source": { - "enum": [ - "search", - "general", - "related" - ], - "type": "string" - }, - "state": { - "enum": [ - "observed", - "partial" - ], - "type": "string" - }, - "total_pages": { - "description": "Known pages; merged regional service lanes expose only the materialized prefix until exhaustion", - "format": "int64", - "type": "integer" - }, - "total_results": { - "description": "Upstream matches before type filtering, or the known deduplicated prefix for merged regional service lanes", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "state", - "source", - "query", - "media_type", - "page", - "total_pages", - "total_results", - "items", - "observations" - ], - "type": "object" - }, - "PayloadSanitizeBlockView": { - "additionalProperties": false, - "properties": { - "dav_dir": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "from_name": { - "type": "string" - }, - "recorded_at": { - "type": "string" - }, - "to_name": { - "type": "string" - } - }, - "required": [ - "dav_dir", - "from_name", - "to_name", - "detail", - "recorded_at" - ], - "type": "object" - }, - "PerformerDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/PerformerDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "also_known_as": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "biography": { - "type": "string" - }, - "birthday": { - "type": "string" - }, - "deathday": { - "type": "string" - }, - "known_for_department": { - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "name": { - "type": "string" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "place_of_birth": { - "type": "string" - }, - "profile_url": { - "type": "string" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - } - }, - "required": [ - "performer_id", - "metadata_state", - "source" - ], - "type": "object" - }, - "Person": { - "additionalProperties": false, - "properties": { - "key": { - "type": "string" - }, - "name": { - "description": "Jellyfin username the household member is known by", - "type": "string" - } - }, - "required": [ - "key", - "name" - ], - "type": "object" - }, - "PrimaryMove": { - "additionalProperties": false, - "properties": { - "bucket": { - "type": "string" - }, - "new_path": { - "type": "string" - }, - "old_path": { - "type": "string" - } - }, - "required": [ - "bucket", - "old_path", - "new_path" - ], - "type": "object" - }, - "ProvenanceItem": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "media_type": { - "type": "string" - }, - "owner": { - "type": "string" - }, - "source": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "owner", - "arr", - "source" - ], - "type": "object" - }, - "Provider": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "logo_url": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "RailStats": { - "additionalProperties": false, - "properties": { - "failed": { - "format": "int64", - "type": "integer" - }, - "last_import": { - "type": "string" - }, - "missing": { - "format": "int64", - "type": "integer" - }, - "queue": { - "format": "int64", - "type": "integer" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "size_bytes", - "queue", - "missing", - "failed" - ], - "type": "object" - }, - "Rating": { - "additionalProperties": false, - "properties": { - "score": { - "description": "RT is 0–100; IMDb and TMDB are 0–10. Absent is never zero.", - "format": "double", - "type": "number" - }, - "source": { - "enum": [ - "rt_critics", - "rt_audience", - "imdb", - "tmdb" - ], - "type": "string" - }, - "state": { - "enum": [ - "observed", - "unrated", - "missing", - "unavailable" - ], - "type": "string" - }, - "status": { - "description": "Observed RT critics or audience label, never inferred from a percentage.", - "type": "string" - }, - "url": { - "type": "string" - }, - "votes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "source", - "state" - ], - "type": "object" - }, - "ReadProfile": { - "additionalProperties": false, - "properties": { - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "key", - "name" - ], - "type": "object" - }, - "ReadinessStage": { - "additionalProperties": false, - "properties": { - "name": { - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "observed_at": { - "description": "When the evidence was last observed (RFC 3339 UTC), when the owner records it", - "type": "string" - }, - "source": { - "description": "The evidence owner this stage reads; it never stands in for another stage", - "type": "string" - }, - "state": { - "enum": [ - "done", - "partial", - "in_progress", - "none", - "unknown", - "stale", - "conflicting", - "failed" - ], - "type": "string" - }, - "summary": { - "description": "What this stage's own evidence says, including why it is unknown, stale, conflicting or failed", - "type": "string" - } - }, - "required": [ - "name", - "state", - "summary", - "source" - ], - "type": "object" - }, - "RecentOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecentOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "entries": { - "items": { - "$ref": "#/components/schemas/DispatchLogEntry" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "entries" - ], - "type": "object" - }, - "RecommendationCoverage": { - "additionalProperties": false, - "properties": { - "complete": { - "description": "Successful ranked snapshot and all active predicates observed for its scoped candidates; not freshness, catalogue completeness or playback proof", - "type": "boolean" - }, - "excluded": { - "format": "int64", - "type": "integer" - }, - "matched": { - "format": "int64", - "type": "integer" - }, - "total": { - "format": "int64", - "type": "integer" - }, - "unresolved": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "total", - "matched", - "excluded", - "unresolved", - "complete" - ], - "type": "object" - }, - "RecommendationDismissal": { - "additionalProperties": false, - "properties": { - "hidden_at": { - "format": "date-time", - "type": "string" - }, - "hidden_by": { - "description": "Household requester key of the operator who hid it", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "poster_path": { - "type": "string" - }, - "title": { - "description": "Display name captured when hidden; empty when it was unknown", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id", - "title", - "year", - "poster_path", - "hidden_by", - "hidden_at" - ], - "type": "object" - }, - "RecommendationDismissals": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationDismissals.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "dismissals": { - "items": { - "$ref": "#/components/schemas/RecommendationDismissal" - }, - "type": [ - "array", - "null" - ] - }, - "profile": { - "type": "string" - } - }, - "required": [ - "profile", - "dismissals" - ], - "type": "object" - }, - "RecommendationFact": { - "additionalProperties": false, - "properties": { - "genre_source": { - "description": "Ranked genre context takes precedence; metadata is a fallback only when ranked genres are unknown", - "enum": [ - "ranked", - "metadata", - "unknown" - ], - "type": "string" - }, - "match": { - "enum": [ - "matched", - "excluded", - "unresolved" - ], - "type": "string" - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - } - }, - "required": [ - "metadata", - "match", - "genre_source" - ], - "type": "object" - }, - "RecommendationFeed": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationFeed.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "attempted_at": { - "format": "date-time", - "type": "string" - }, - "expires_at": { - "description": "observed_at plus the configured feed interval; expiry only, not a scheduled refresh or retry", - "format": "date-time", - "type": "string" - }, - "failure": { - "enum": [ - "profile_resolution", - "memberships", - "history", - "candidates", - "cycle" - ], - "type": "string" - }, - "materialization": { - "enum": [ - "unknown", - "pending", - "current", - "failed" - ], - "type": "string" - }, - "movies": { - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - }, - "observed_at": { - "format": "date-time", - "type": "string" - }, - "outcome": { - "enum": [ - "unknown", - "ranked", - "no_seeds", - "empty" - ], - "type": "string" - }, - "profile": { - "$ref": "#/components/schemas/ReadProfile" - }, - "projection": { - "$ref": "#/components/schemas/RecommendationProjection" - }, - "shows": { - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "current", - "stale", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "projection", - "profile", - "state", - "outcome", - "materialization", - "movies", - "shows" - ], - "type": "object" - }, - "RecommendationHideInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationHideInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "poster_path": { - "maxLength": 200, - "type": "string" - }, - "title": { - "description": "Display hint, used only when the retained feed no longer carries the title", - "maxLength": 300, - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "minimum": 1, - "type": "integer" - }, - "year": { - "format": "int64", - "maximum": 9999, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "RecommendationHideResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationHideResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "created": { - "description": "False when the title was already hidden", - "type": "boolean" - }, - "dismissal": { - "$ref": "#/components/schemas/RecommendationDismissal" - }, - "materialization": { - "description": "Jellyfin recommendation folders after the hide; failed leaves the dismissal in force until the next cycle converges them", - "enum": [ - "current", - "failed", - "unchanged" - ], - "type": "string" - }, - "profile": { - "type": "string" - } - }, - "required": [ - "profile", - "dismissal", - "created", - "materialization" - ], - "type": "object" - }, - "RecommendationProfiles": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationProfiles.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "default_profile": { - "type": "string" - }, - "profiles": { - "items": { - "$ref": "#/components/schemas/ReadProfile" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "profiles", - "default_profile" - ], - "type": "object" - }, - "RecommendationProjection": { - "additionalProperties": false, - "properties": { - "coverage": { - "$ref": "#/components/schemas/RecommendationCoverage" - }, - "facts": { - "description": "Scoped candidates in original movie rank followed by original series rank; unknown observations never prove exclusion", - "items": { - "$ref": "#/components/schemas/RecommendationFact" - }, - "type": [ - "array", - "null" - ] - }, - "genre_id": { - "format": "int64", - "type": "integer" - }, - "media_type": { - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - }, - "service_id": { - "format": "int64", - "type": "integer" - }, - "unresolved": { - "description": "Possible matches with unknown active predicates, in original per-media relative order", - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "media_type", - "genre_id", - "service_id", - "coverage", - "facts", - "unresolved" - ], - "type": "object" - }, - "RecommendationRestoreResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationRestoreResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "profile": { - "type": "string" - }, - "restored": { - "description": "False when the title was not hidden", - "type": "boolean" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "profile", - "media_type", - "tmdb_id", - "restored" - ], - "type": "object" - }, - "RecommendationSyncResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationSyncResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "state": { - "enum": [ - "healthy", - "degraded", - "unhealthy" - ], - "type": "string" - }, - "status": { - "description": "synced", - "type": "string" - } - }, - "required": [ - "status", - "state" - ], - "type": "object" - }, - "RedispatchInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RedispatchInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "description": "items to re-dispatch", - "items": { - "$ref": "#/components/schemas/RedispatchItem" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "RedispatchItem": { - "additionalProperties": false, - "properties": { - "media_type": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type" - ], - "type": "object" - }, - "RedispatchOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RedispatchOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "results": { - "items": { - "$ref": "#/components/schemas/RedispatchResult" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "results" - ], - "type": "object" - }, - "RedispatchResult": { - "additionalProperties": false, - "properties": { - "media_type": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "status": { - "description": "ok | partial | failed | skipped", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "status" - ], - "type": "object" - }, - "RegrabRequest": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RegrabRequest.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "episode_ids": { - "description": "arr episode ids to regrab (tv)", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "episode_keys": { - "description": "SxxEyy keys to regrab (tv)", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "season": { - "description": "regrab a whole season (tv)", - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, - "RegrabResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RegrabResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "blocklisted": { - "format": "int64", - "type": "integer" - }, - "cleared": { - "format": "int64", - "type": "integer" - }, - "detail": { - "type": "string" - }, - "searched": { - "format": "int64", - "type": "integer" - }, - "status": { - "description": "ok | busy | superseded | missing_in_arr | invalid | arr_error", - "type": "string" - } - }, - "required": [ - "status", - "blocklisted", - "cleared", - "searched" - ], - "type": "object" - }, - "RemovalLink": { - "additionalProperties": false, - "properties": { - "bucket": { - "type": "string" - }, - "orphan": { - "type": "boolean" - }, - "path": { - "type": "string" - }, - "target": { - "type": "string" - } - }, - "required": [ - "bucket", - "path", - "target", - "orphan" - ], - "type": "object" - }, - "RemoveFromQueueInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RemoveFromQueueInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "reason": { - "description": "required non-empty audited queue-removal reason", - "type": "string" - }, - "then": { - "description": "reopen for a new grab, or resolve an already-imported/duplicate issue", - "enum": [ - "reopen", - "resolve" - ], - "type": "string" - } - }, - "required": [ - "reason", - "then" - ], - "type": "object" - }, - "RenameHistoryOutputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RenameHistoryOutputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "passes": { - "items": { - "$ref": "#/components/schemas/RenamePass" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "passes" - ], - "type": "object" - }, - "RenamePass": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "ended_at": { - "type": "string" - }, - "files": { - "format": "int64", - "type": "integer" - }, - "pass_id": { - "format": "int64", - "type": "integer" - }, - "plan_json": { - "type": "string" - }, - "repointed": { - "format": "int64", - "type": "integer" - }, - "request_id": { - "description": "Opaque originating request identity, retained in the durable plan; absent for legacy or uncorrelated callers. Correlation only, not idempotency.", - "type": "string" - }, - "series_id": { - "format": "int64", - "type": "integer" - }, - "started_at": { - "type": "string" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "watch_items": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "pass_id", - "tmdb_id", - "arr", - "series_id", - "title", - "status", - "plan_json", - "files", - "watch_items", - "repointed", - "detail", - "started_at", - "ended_at" - ], - "type": "object" - }, - "RenamePlan": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "files": { - "items": { - "$ref": "#/components/schemas/RenamePlanFile" - }, - "type": [ - "array", - "null" - ] - }, - "series_id": { - "format": "int64", - "type": "integer" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "arr", - "series_id", - "title", - "files" - ], - "type": "object" - }, - "RenamePlanBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RenamePlanBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "plan": { - "$ref": "#/components/schemas/RenamePlan", - "description": "the holding Sonarr, the series, and each pending file's existing and new path" - } - }, - "required": [ - "plan" - ], - "type": "object" - }, - "RenamePlanFile": { - "additionalProperties": false, - "properties": { - "episode_file_id": { - "format": "int64", - "type": "integer" - }, - "episode_numbers": { - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "existing_path": { - "type": "string" - }, - "new_path": { - "type": "string" - }, - "season_number": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "episode_file_id", - "season_number", - "episode_numbers", - "existing_path", - "new_path" - ], - "type": "object" - }, - "RenameResult": { - "additionalProperties": false, - "properties": { - "converged": { - "type": "boolean" - }, - "detail": { - "type": "string" - }, - "pass_id": { - "format": "int64", - "type": "integer" - }, - "plan": { - "$ref": "#/components/schemas/RenamePlan" - }, - "renamed": { - "format": "int64", - "type": "integer" - }, - "repointed": { - "items": { - "$ref": "#/components/schemas/RepointedLink" - }, - "type": [ - "array", - "null" - ] - }, - "skipped": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "status": { - "type": "string" - }, - "watch_items": { - "format": "int64", - "type": "integer" - }, - "watch_snapshot": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "pass_id", - "plan", - "status", - "renamed", - "watch_items", - "watch_snapshot", - "converged" - ], - "type": "object" - }, - "RenameResultBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RenameResultBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "result": { - "$ref": "#/components/schemas/RenameResult", - "description": "the pass outcome with the plan it ran, the watch-state counts and any repointed layer links" - } - }, - "required": [ - "result" - ], - "type": "object" - }, - "ReplacementHoldReleaseResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ReplacementHoldReleaseResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "released": { - "type": "boolean" - } - }, - "required": [ - "released" - ], - "type": "object" - }, - "ReplacementHoldView": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "created_at": { - "format": "date-time", - "type": "string" - }, - "expires_at": { - "format": "date-time", - "type": "string" - }, - "reason": { - "type": "string" - }, - "record_id": { - "format": "int64", - "type": "integer" - }, - "record_type": { - "type": "string" - }, - "requested_by": { - "type": "string" - } - }, - "required": [ - "arr", - "record_type", - "record_id", - "requested_by", - "reason", - "created_at", - "expires_at" - ], - "type": "object" - }, - "ReplacementHoldsResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ReplacementHoldsResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "holds": { - "items": { - "$ref": "#/components/schemas/ReplacementHoldView" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "holds" - ], - "type": "object" - }, - "RepointedLink": { - "additionalProperties": false, - "properties": { - "bucket": { - "type": "string" - }, - "dir": { - "type": "string" - }, - "new_name": { - "type": "string" - }, - "new_target": { - "type": "string" - }, - "old_name": { - "type": "string" - }, - "old_target": { - "type": "string" - } - }, - "required": [ - "bucket", - "dir", - "old_name", - "new_name", - "old_target", - "new_target" - ], - "type": "object" - }, - "RequestIntentBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RequestIntentBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "description": "Household requester key to attribute the request to; defaults to the signed-in operator's own requester and must be one of the operator's capability requesters", - "type": "string" - }, - "seasons": { - "description": "Television only: the explicit seasons wanted (0 is specials). An empty selection is refused, never read as all seasons. Omit for a movie", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "default": "title", - "description": "Recommendation requires recommendation_profile and settles that profile's own acceptance only when it is also the requester", - "enum": [ - "title", - "recommendation" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "RequestProgress": { - "additionalProperties": false, - "properties": { - "actor": { - "$ref": "#/components/schemas/Actor", - "description": "Who filed it, as recorded; absent when the request was filed outside the cockpit" - }, - "awaiting_approval": { - "description": "The request is filed in Seerr and nothing has been dispatched, imported or observed yet, so no acquisition has started. Absent otherwise", - "type": "boolean" - }, - "blocking_stage": { - "description": "First stage without done evidence. Absent only when every stage has it. Not a readiness claim", - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "filed_by_you": { - "description": "The signed-in operator filed this command", - "type": "boolean" - }, - "id": { - "description": "request-N for a cockpit command, dispatch-N for a request Seerr approved outside the cockpit", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "on_behalf": { - "description": "The actor's own requester differs from the attributed requester", - "type": "boolean" - }, - "requested_at": { - "type": "string" - }, - "requester": { - "$ref": "#/components/schemas/Person", - "description": "The household member the request is attributed to; this decides which feed the item belongs to" - }, - "seasons": { - "description": "Television seasons explicitly requested; empty for a movie or when not recorded", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "stages": { - "items": { - "$ref": "#/components/schemas/ReadinessStage" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "description": "Known from the dispatch log or library membership; empty when no local record names the title yet", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "id", - "tmdb_id", - "media_type", - "requester", - "on_behalf", - "filed_by_you", - "seasons", - "stages" - ], - "type": "object" - }, - "RequestReceipt": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RequestReceipt.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "actor": { - "$ref": "#/components/schemas/RequestReceiptActor", - "description": "Authenticated operator who issued the command; separate from the requester" - }, - "attempts": { - "format": "int64", - "type": "integer" - }, - "coverage": { - "description": "One outcome per selected season, never inferred from other seasons", - "items": { - "$ref": "#/components/schemas/RequestReceiptCoverage" - }, - "type": [ - "array", - "null" - ] - }, - "created_at": { - "type": "string" - }, - "detail": { - "description": "Why the command is pending or what was refused", - "type": "string" - }, - "id": { - "description": "Native request record; retrying the same command resumes this record", - "format": "int64", - "type": "integer" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "membership": { - "$ref": "#/components/schemas/RequestReceiptMembership" - }, - "on_behalf": { - "description": "The requester differs from the acting operator's own household requester", - "type": "boolean" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "description": "Household requester the request is attributed to", - "type": "string" - }, - "requester_username": { - "type": "string" - }, - "seasons": { - "description": "Explicit television seasons the operator asked for; empty for a movie", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "seerr": { - "$ref": "#/components/schemas/RequestReceiptSeerr" - }, - "source": { - "enum": [ - "title", - "recommendation" - ], - "type": "string" - }, - "state": { - "description": "Settled: every part has a verified outcome. Pending: a fact is unresolved. Retired: an administrator ended a stale-identity pending command without cancelling any Seerr request or reversing media effects", - "enum": [ - "settled", - "pending", - "retired" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "media_type", - "tmdb_id", - "state", - "requester", - "requester_username", - "actor", - "on_behalf", - "source", - "seasons", - "membership", - "seerr", - "coverage", - "attempts", - "created_at", - "updated_at" - ], - "type": "object" - }, - "RequestReceiptActor": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "requester": { - "description": "The acting operator's own household requester key", - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - } - }, - "required": [ - "name", - "role", - "requester" - ], - "type": "object" - }, - "RequestReceiptCoverage": { - "additionalProperties": false, - "properties": { - "outcome": { - "enum": [ - "requested", - "already_requested", - "already_held", - "unresolved" - ], - "type": "string" - }, - "request_id": { - "format": "int64", - "type": "integer" - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "outcome" - ], - "type": "object" - }, - "RequestReceiptMembership": { - "additionalProperties": false, - "properties": { - "bucket": { - "description": "Library route the membership landed in", - "type": "string" - }, - "dispatch_log_id": { - "format": "int64", - "type": "integer" - }, - "status": { - "enum": [ - "added", - "already_owned", - "pending_dispatch", - "conflict", - "failed" - ], - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "RequestReceiptSeerr": { - "additionalProperties": false, - "properties": { - "outcome": { - "enum": [ - "requested", - "already_requested", - "not_needed", - "not_attempted", - "unresolved" - ], - "type": "string" - }, - "request_id": { - "description": "Seerr request attributed to the requester that covers this command", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "outcome" - ], - "type": "object" - }, - "RestartResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RestartResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "kind": { - "description": "process | sibling", - "type": "string" - }, - "status": { - "description": "restarting | degraded", - "type": "string" - }, - "target": { - "type": "string" - } - }, - "required": [ - "target", - "kind", - "status" - ], - "type": "object" - }, - "ResumeBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ResumeBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/ResumeTitle" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "items" - ], - "type": "object" - }, - "ResumeTitle": { - "additionalProperties": false, - "properties": { - "episode": { - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "progress": { - "format": "double", - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "progress" - ], - "type": "object" - }, - "Row": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "candidate_health": { - "type": "string" - }, - "candidate_key": { - "type": "string" - }, - "candidate_reason": { - "type": "string" - }, - "download_id": { - "type": "string" - }, - "episode": { - "type": "string" - }, - "episode_count": { - "format": "int64", - "type": "integer" - }, - "eta": { - "type": "string" - }, - "progress": { - "format": "double", - "type": "number" - }, - "protocol": { - "type": "string" - }, - "source": { - "type": "string" - }, - "state": { - "type": "string" - }, - "state_detail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "title", - "protocol", - "progress", - "state", - "source" - ], - "type": "object" - }, - "Season": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "coverage": { - "enum": [ - "none", - "partial", - "complete", - "unknown" - ], - "type": "string" - }, - "episode_count": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - }, - "number": { - "format": "int64", - "type": "integer" - }, - "overview": { - "type": "string" - }, - "recorded_episodes": { - "format": "int64", - "type": "integer" - }, - "request_state": { - "enum": [ - "none", - "requested", - "unknown" - ], - "type": "string" - }, - "seerr_state": { - "enum": [ - "untracked", - "unknown", - "pending", - "processing", - "partial", - "available", - "blocklisted", - "deleted" - ], - "type": "string" - } - }, - "required": [ - "number", - "name", - "overview", - "episode_count", - "recorded_episodes", - "coverage", - "request_state", - "seerr_state" - ], - "type": "object" - }, - "SeasonCoverage": { - "additionalProperties": false, - "properties": { - "coverage": { - "enum": [ - "complete", - "partial", - "none", - "unknown" - ], - "type": "string" - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "coverage" - ], - "type": "object" - }, - "SeasonDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/SeasonDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "episodes": { - "items": { - "$ref": "#/components/schemas/Episode" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "season": { - "$ref": "#/components/schemas/Season" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state", - "season", - "episodes", - "observations" - ], - "type": "object" - }, - "SeasonGroup": { - "additionalProperties": false, - "properties": { - "episodes": { - "items": { - "$ref": "#/components/schemas/EpisodeRow" - }, - "type": [ - "array", - "null" - ] - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "episodes" - ], - "type": "object" - }, - "SeedContribution": { - "additionalProperties": false, - "properties": { - "added": { - "description": "A successful attributed addition contributed nonzero seed weight", - "type": "boolean" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "title": { - "description": "Observed display name; absent when unknown", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "watched": { - "description": "A played history event contributed nonzero seed weight", - "type": "boolean" - } - }, - "required": [ - "watched", - "added", - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "SeerrObservation": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "requests": { - "items": { - "$ref": "#/components/schemas/SeerrRequest" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state", - "requests" - ], - "type": "object" - }, - "SeerrRequest": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "status": { - "enum": [ - "pending", - "approved", - "declined", - "failed", - "completed", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "id", - "status" - ], - "type": "object" - }, - "SelectEpisodesInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/SelectEpisodesInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "episode_ids": { - "description": "explicit arr episode ids to import (re-verified aired+monitored+belonging server-side)", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "episode_ids" - ], - "type": "object" - }, - "SourceDetail": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "imported": { - "description": "import timestamp (RFC3339)", - "type": "string" - }, - "indexer": { - "type": "string" - } - }, - "required": [ - "arr" - ], - "type": "object" - }, - "SourceUsage": { - "additionalProperties": false, - "properties": { - "item_count": { - "format": "int64", - "type": "integer" - }, - "source": { - "type": "string" - } - }, - "required": [ - "source", - "item_count" - ], - "type": "object" - }, - "SpottarrFreshnessView": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/SpottarrFreshnessView.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "age": { - "type": "string" - }, - "age_seconds": { - "format": "int64", - "type": "integer" - }, - "checked_at": { - "format": "date-time", - "type": "string" - }, - "count": { - "format": "int64", - "type": "integer" - }, - "detail": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "known": { - "type": "boolean" - }, - "newest": { - "format": "date-time", - "type": "string" - }, - "state": { - "enum": [ - "disabled", - "starting", - "healthy", - "degraded", - "unhealthy", - "stopped" - ], - "type": "string" - } - }, - "required": [ - "enabled", - "known", - "state" - ], - "type": "object" - }, - "Title": { - "additionalProperties": false, - "properties": { - "backdrop_url": { - "description": "Landscape artwork on the TMDB image CDN; absent when none is observed or the upstream path is not a CDN-relative path", - "type": "string" - }, - "genre_ids": { - "description": "TMDB genre IDs in this title's media-type namespace; absent is unobserved, empty is observed without genres", - "items": { - "format": "int64", - "type": "integer" - }, - "type": "array" - }, - "held": { - "enum": [ - "held", - "unheld", - "unknown" - ], - "type": "string" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "overview": { - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "release_date": { - "type": "string" - }, - "request_state": { - "enum": [ - "none", - "requested", - "unknown" - ], - "type": "string" - }, - "seerr_state": { - "enum": [ - "untracked", - "unknown", - "pending", - "processing", - "partial", - "available", - "blocklisted", - "deleted" - ], - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "overview", - "held", - "request_state", - "seerr_state", - "libraries" - ], - "type": "object" - }, - "TitleCast": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleCast.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "credit_context": { - "description": "Seerr exposes only each TV performer's most frequent aggregate role, not every role or episode count", - "enum": [ - "movie-character", - "tv-leading-character" - ], - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/CastMember" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "paging": { - "description": "One complete returned Seerr cast list, not invented upstream pages", - "enum": [ - "unpaged" - ], - "type": "string" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state", - "source", - "paging", - "credit_context", - "items" - ], - "type": "object" - }, - "TitleDiagnosis": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleDiagnosis.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "candidates": { - "items": { - "$ref": "#/components/schemas/TriageDetail" - }, - "type": [ - "array", - "null" - ] - }, - "dispatch": { - "items": { - "$ref": "#/components/schemas/DispatchLogEntry" - }, - "type": [ - "array", - "null" - ] - }, - "dispatch_observation": { - "$ref": "#/components/schemas/TitleDiagnosisObservation" - }, - "imports_observation": { - "$ref": "#/components/schemas/TitleDiagnosisObservation" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "missing": { - "items": { - "$ref": "#/components/schemas/MissingItem" - }, - "type": [ - "array", - "null" - ] - }, - "presence_observation": { - "$ref": "#/components/schemas/TitleDiagnosisObservation" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "candidates", - "dispatch", - "missing", - "imports_observation", - "dispatch_observation", - "presence_observation" - ], - "type": "object" - }, - "TitleDiagnosisObservation": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state" - ], - "type": "object" - }, - "TitleMediaHealth": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleMediaHealth.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "breaker": { - "$ref": "#/components/schemas/MediaHealthBreakerResult" - }, - "enabled": { - "type": "boolean" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "proposals": { - "items": { - "$ref": "#/components/schemas/TitleMediaHealthProposal" - }, - "type": [ - "array", - "null" - ] - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "enabled", - "breaker", - "proposals" - ], - "type": "object" - }, - "TitleMediaHealthFile": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "file_id": { - "format": "int64", - "type": "integer" - }, - "path": { - "type": "string" - }, - "record_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "arr", - "record_id", - "file_id", - "path" - ], - "type": "object" - }, - "TitleMediaHealthProposal": { - "additionalProperties": false, - "properties": { - "files": { - "items": { - "$ref": "#/components/schemas/TitleMediaHealthFile" - }, - "type": [ - "array", - "null" - ] - }, - "first_attempt_at": { - "format": "date-time", - "type": "string" - }, - "history": { - "items": { - "$ref": "#/components/schemas/MediaHealthRepairEvent" - }, - "type": [ - "array", - "null" - ] - }, - "proposal": { - "$ref": "#/components/schemas/MediaHealthProposalView" - }, - "usenet_attempts": { - "format": "int64", - "type": "integer" - }, - "verify_attempts": { - "description": "Recorded inconclusive verification retries; a successful first read does not increment this", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "files", - "usenet_attempts", - "verify_attempts", - "proposal", - "history" - ], - "type": "object" - }, - "TitleMetadata": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleMetadata.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "checked_at": { - "format": "date-time", - "type": "string" - }, - "genre_state": { - "enum": [ - "unobserved", - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "genres": { - "items": { - "$ref": "#/components/schemas/Genre" - }, - "type": [ - "array", - "null" - ] - }, - "genres_observed_at": { - "format": "date-time", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "provider_state": { - "enum": [ - "unobserved", - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "providers": { - "items": { - "$ref": "#/components/schemas/Provider" - }, - "type": [ - "array", - "null" - ] - }, - "providers_observed_at": { - "format": "date-time", - "type": "string" - }, - "region": { - "enum": [ - "NL" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "region", - "genre_state", - "provider_state", - "genres", - "providers" - ], - "type": "object" - }, - "TitleRatings": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRatings.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "ratings" - ], - "type": "object" - }, - "TitleRequestsBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRequestsBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "requests": { - "items": { - "$ref": "#/components/schemas/RequestReceipt" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "requests" - ], - "type": "object" - }, - "TitleVersion": { - "additionalProperties": false, - "properties": { - "file": { - "$ref": "#/components/schemas/FileDetail" - }, - "role": { - "enum": [ - "primary", - "mirror" - ], - "type": "string" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/SeasonGroup" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "type": "string" - } - }, - "required": [ - "source", - "role" - ], - "type": "object" - }, - "TorrentArmInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentArmInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "acknowledge_trip": { - "description": "The owner acknowledged the trip reason; required to re-arm a tripped lane", - "type": "boolean" - }, - "revision": { - "description": "Plan revision the arm confirm listed", - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "revision" - ], - "type": "object" - }, - "TorrentArmingFloor": { - "additionalProperties": false, - "properties": { - "dead_file": { - "description": "At least one acceptable would-push for a dead-file proposal", - "type": "boolean" - }, - "missing": { - "description": "At least one acceptable would-push for a missing record", - "type": "boolean" - } - }, - "required": [ - "dead_file", - "missing" - ], - "type": "object" - }, - "TorrentArrRejectionCount": { - "additionalProperties": false, - "properties": { - "count": { - "description": "Number of releases with this rejection", - "format": "int64", - "type": "integer" - }, - "rejection": { - "description": "Unsupported rejection returned by the Arr", - "type": "string" - } - }, - "required": [ - "rejection", - "count" - ], - "type": "object" - }, - "TorrentCandidateView": { - "additionalProperties": false, - "properties": { - "arr_kind": { - "description": "Arr kind holding the record", - "enum": [ - "sonarr", - "radarr" - ], - "type": "string" - }, - "arr_name": { - "description": "Primary Arr instance holding the record", - "type": "string" - }, - "attempts": { - "description": "Failed torrent attempts spent in the record's current qualification window", - "format": "int64", - "type": "integer" - }, - "attempts_per_window": { - "description": "Failed attempts one qualification window allows", - "format": "int64", - "type": "integer" - }, - "available_at": { - "description": "When the record became available (air date, or Radarr availability)", - "format": "date-time", - "type": "string" - }, - "detail": { - "description": "Human-readable state of the clock, or the outcome that set the record's budget stage", - "type": "string" - }, - "episode_key": { - "description": "SxxEyy for an episode, empty for a movie", - "type": "string" - }, - "kind": { - "description": "dead_file: a repair proposal's record; missing: an Arr missing monitored record", - "enum": [ - "dead_file", - "missing" - ], - "type": "string" - }, - "last_search_at": { - "description": "The lane's latest own usenet search", - "format": "date-time", - "type": "string" - }, - "last_search_outcome": { - "description": "Outcome of the lane's latest own usenet search", - "enum": [ - "silent", - "found", - "transport", - "error" - ], - "type": "string" - }, - "ledger": { - "description": "Ledger tracking the record's attempts: the repair proposal row, or import_candidates (source torrent)", - "enum": [ - "repair", - "import" - ], - "type": "string" - }, - "new_silent_days": { - "description": "Answered-silent days credited since the clock (or the seed) started", - "format": "int64", - "type": "integer" - }, - "next_at": { - "description": "Earliest a waiting, retry-due or resting record can start again, assuming one answered-silent day per day", - "format": "date-time", - "type": "string" - }, - "paused": { - "description": "A usenet grab or repair is in flight; the silence count is paused", - "type": "boolean" - }, - "proposal_id": { - "description": "Repair proposal for a dead_file record", - "format": "int64", - "type": "integer" - }, - "qualified": { - "description": "Enough silence days, and a seeded record has at least one new one", - "type": "boolean" - }, - "record_id": { - "description": "Arr episode or movie id; the clock and budget belong to this record", - "format": "int64", - "type": "integer" - }, - "record_type": { - "description": "Arr record type", - "enum": [ - "episode", - "movie" - ], - "type": "string" - }, - "searching_since": { - "description": "The conductarr-owned usenet-searching-since stamp; the clock start", - "format": "date-time", - "type": "string" - }, - "seeded": { - "description": "The count was seeded from history at the lane's first pass", - "type": "boolean" - }, - "silence_days": { - "description": "Answered-silent days, seeded proxy days included", - "format": "int64", - "type": "integer" - }, - "silence_needed": { - "description": "Silence days that qualify a record", - "format": "int64", - "type": "integer" - }, - "stage": { - "description": "Lane stage: silent while the record accrues answered-silent days; waiting after a start that spent no attempt (no acceptable release, or a magnet with no peers); retry due after a failed attempt with attempts left; resting once the window's attempts are spent", - "enum": [ - "silent", - "waiting", - "retry due", - "resting" - ], - "type": "string" - }, - "title": { - "description": "Series or movie title", - "type": "string" - }, - "tmdb_id": { - "description": "TMDB id of the series or movie", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "arr_kind", - "arr_name", - "record_type", - "record_id", - "tmdb_id", - "title", - "kind", - "ledger", - "stage", - "paused", - "silence_days", - "silence_needed", - "seeded", - "new_silent_days", - "qualified", - "available_at", - "searching_since", - "attempts", - "attempts_per_window", - "detail" - ], - "type": "object" - }, - "TorrentCandidatesResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentCandidatesResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "candidates": { - "description": "Dead files first, then missing records, longest silence first", - "items": { - "$ref": "#/components/schemas/TorrentCandidateView" - }, - "type": [ - "array", - "null" - ] - }, - "qualified": { - "description": "How many rows qualify", - "format": "int64", - "type": "integer" - }, - "silence_needed": { - "description": "Silence days that qualify a record", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "silence_needed", - "qualified", - "candidates" - ], - "type": "object" - }, - "TorrentCoverage": { - "additionalProperties": false, - "properties": { - "anime": { - "$ref": "#/components/schemas/TorrentCoverageSlice", - "description": "Records on an anime Arr" - }, - "dead_file": { - "$ref": "#/components/schemas/TorrentCoverageSlice", - "description": "Dead-file proposal records" - }, - "missing": { - "$ref": "#/components/schemas/TorrentCoverageSlice", - "description": "Missing monitored records" - }, - "non_anime": { - "$ref": "#/components/schemas/TorrentCoverageSlice", - "description": "Records on a non-anime Arr" - } - }, - "required": [ - "dead_file", - "missing", - "anime", - "non_anime" - ], - "type": "object" - }, - "TorrentCoverageSlice": { - "additionalProperties": false, - "properties": { - "acceptable": { - "description": "Of those, rows holding an acceptable release (a would-push)", - "format": "int64", - "type": "integer" - }, - "dry_runs": { - "description": "Plan rows whose latest torrent search ran", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "dry_runs", - "acceptable" - ], - "type": "object" - }, - "TorrentDiskProjection": { - "additionalProperties": false, - "properties": { - "bytes": { - "description": "Summed size of those rows' would-push releases", - "format": "int64", - "type": "integer" - }, - "floor_bytes": { - "description": "Configured free-space floor below which no start runs", - "format": "int64", - "type": "integer" - }, - "free_bytes": { - "description": "Free space on the /data/import dataset when the plan was built", - "format": "int64", - "type": "integer" - }, - "starts": { - "description": "Plan rows counted: the first ones, up to the rolling start cap", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "starts", - "bytes", - "free_bytes", - "floor_bytes" - ], - "type": "object" - }, - "TorrentHandoff": { - "additionalProperties": false, - "properties": { - "arr_gate_drops": { - "items": { - "$ref": "#/components/schemas/ArrGateDrop" - }, - "type": [ - "array", - "null" - ] - }, - "candidate": { - "$ref": "#/components/schemas/TorrentCandidateView" - }, - "finished_at": { - "format": "date-time", - "type": "string" - }, - "note": { - "type": "string" - }, - "outcome": { - "type": "string" - }, - "started_at": { - "format": "date-time", - "type": "string" - } - }, - "required": [ - "started_at", - "finished_at", - "candidate", - "outcome", - "note" - ], - "type": "object" - }, - "TorrentHistoryResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentHistoryResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "events": { - "items": { - "$ref": "#/components/schemas/TorrentLaneEvent" - }, - "type": [ - "array", - "null" - ] - }, - "handoffs": { - "items": { - "$ref": "#/components/schemas/TorrentHandoff" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "handoffs", - "events" - ], - "type": "object" - }, - "TorrentIndexerDrops": { - "additionalProperties": false, - "properties": { - "arr_rejections": { - "description": "Distinct unsupported Arr rejection strings and their release counts", - "items": { - "$ref": "#/components/schemas/TorrentArrRejectionCount" - }, - "type": [ - "array", - "null" - ] - }, - "coverage": { - "description": "Maps an episode that is not a monitored, qualified record without a usenet file", - "format": "int64", - "type": "integer" - }, - "gate": { - "description": "Refused by the Arr for any reason but ProtocolDisabled (or, for a dead file, the existing file)", - "format": "int64", - "type": "integer" - }, - "indexer": { - "description": "Indexer display name as the Arr reports it", - "type": "string" - }, - "language": { - "description": "Language: Not Original on a non-anime Arr, or dub-only on an anime Arr", - "format": "int64", - "type": "integer" - }, - "seeders": { - "description": "Fewer seeders than the minimum, or an unknown count", - "format": "int64", - "type": "integer" - }, - "tried": { - "description": "GUID or infohash already tried for a covered record, unprovable, or in the release-corpse ledger", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "indexer", - "gate", - "language", - "seeders", - "coverage", - "tried" - ], - "type": "object" - }, - "TorrentLaneEvent": { - "additionalProperties": false, - "properties": { - "at": { - "format": "date-time", - "type": "string" - }, - "detail": { - "type": "string" - }, - "kind": { - "description": "arm and disarm by the owner, a breaker trip, or unrecorded torrents removed at a qBittorrent child start", - "enum": [ - "arm", - "disarm", - "trip", - "unrecorded_removed" - ], - "type": "string" - } - }, - "required": [ - "at", - "kind", - "detail" - ], - "type": "object" - }, - "TorrentPick": { - "additionalProperties": false, - "properties": { - "guid": { - "type": "string" - }, - "indexer_id": { - "format": "int64", - "type": "integer" - }, - "release": { - "type": "string" - }, - "seeders": { - "format": "int64", - "type": "integer" - }, - "size": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "release", - "indexer_id", - "seeders", - "size", - "guid" - ], - "type": "object" - }, - "TorrentPlanResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentPlanResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "dry_run_at": { - "description": "When the shadow plan was produced", - "format": "date-time", - "type": "string" - }, - "next_rotation": { - "description": "Next time the lane may start a handoff", - "format": "date-time", - "type": "string" - }, - "revision": { - "description": "Monotonic plan revision", - "format": "int64", - "type": "integer" - }, - "rows": { - "description": "Current handoff decisions; no row causes a grab", - "items": { - "$ref": "#/components/schemas/TorrentPlanRow" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "revision", - "dry_run_at", - "rows" - ], - "type": "object" - }, - "TorrentPlanRow": { - "additionalProperties": false, - "properties": { - "arr_gate_drops": { - "description": "Rejections from each release refused by the Arr gate", - "items": { - "$ref": "#/components/schemas/ArrGateDrop" - }, - "type": [ - "array", - "null" - ] - }, - "candidate": { - "$ref": "#/components/schemas/TorrentCandidateView" - }, - "note": { - "type": "string" - }, - "outcome": { - "type": "string" - }, - "pick": { - "$ref": "#/components/schemas/TorrentPick" - }, - "usenet_check": { - "type": "string" - } - }, - "required": [ - "candidate", - "usenet_check", - "outcome", - "note" - ], - "type": "object" - }, - "TorrentRotation": { - "additionalProperties": false, - "properties": { - "day_one": { - "description": "Plan records the lane's first pass saw", - "format": "int64", - "type": "integer" - }, - "dry_run": { - "description": "Day-one plan records that have had a dry run", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "dry_run", - "day_one" - ], - "type": "object" - }, - "TorrentStatusResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentStatusResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "active": { - "format": "int64", - "type": "integer" - }, - "armed_revision": { - "description": "Plan revision the owner armed on; absent in shadow and tripped, kept while paused", - "format": "int64", - "type": "integer" - }, - "child_detail": { - "description": "The qBittorrent child's supervision detail", - "type": "string" - }, - "child_state": { - "description": "The qBittorrent child's supervision state; anything but healthy pauses an armed lane", - "type": "string" - }, - "failure_limit": { - "description": "This many failed attempts in a row trip an armed lane", - "format": "int64", - "type": "integer" - }, - "failure_streak": { - "description": "Failed attempts in a row since the latest import; a handoff that spent no attempt neither counts nor resets it", - "format": "int64", - "type": "integer" - }, - "free_bytes": { - "format": "int64", - "type": "integer" - }, - "mode": { - "description": "shadow: starts are dry runs; armed: a start pushes its pick; tripped: a breaker returned the lane to shadow until an acknowledged re-arm; paused: armed, but the qBittorrent child is not healthy, so nothing starts", - "enum": [ - "shadow", - "armed", - "tripped", - "paused" - ], - "type": "string" - }, - "newly_qualified_24h": { - "description": "Candidates newly qualified in the rolling 24h, the day-one baseline and anything before the latest acknowledged trip left out", - "format": "int64", - "type": "integer" - }, - "next_start": { - "format": "date-time", - "type": "string" - }, - "spike_limit": { - "description": "More newly qualified candidates than this in 24h trip an armed lane", - "format": "int64", - "type": "integer" - }, - "starts_24h": { - "format": "int64", - "type": "integer" - }, - "trip_acknowledged": { - "description": "An arm acknowledged the latest trip", - "type": "boolean" - }, - "trip_reason": { - "description": "Why the latest breaker trip fired; absent before any trip", - "type": "string" - }, - "tripped_at": { - "description": "When the latest breaker trip fired", - "format": "date-time", - "type": "string" - } - }, - "required": [ - "mode", - "starts_24h", - "active", - "free_bytes", - "trip_acknowledged", - "newly_qualified_24h", - "spike_limit", - "failure_streak", - "failure_limit", - "child_state", - "child_detail" - ], - "type": "object" - }, - "TorrentSummaryResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TorrentSummaryResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "arming_floor": { - "$ref": "#/components/schemas/TorrentArmingFloor" - }, - "coverage": { - "$ref": "#/components/schemas/TorrentCoverage" - }, - "indexers": { - "description": "Drops per indexer and rule, by indexer name", - "items": { - "$ref": "#/components/schemas/TorrentIndexerDrops" - }, - "type": [ - "array", - "null" - ] - }, - "projection": { - "$ref": "#/components/schemas/TorrentDiskProjection" - }, - "revision": { - "description": "Plan revision the summary was summed over", - "format": "int64", - "type": "integer" - }, - "rotation": { - "$ref": "#/components/schemas/TorrentRotation" - }, - "usenet_won": { - "description": "Plan rows whose fresh usenet check found an acceptable release", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "revision", - "indexers", - "coverage", - "rotation", - "usenet_won", - "projection", - "arming_floor" - ], - "type": "object" - }, - "TriageCandidate": { - "additionalProperties": false, - "properties": { - "arr": { - "description": "kind/name of the holding MAIN arr", - "type": "string" - }, - "candidate_key": { - "type": "string" - }, - "episode_ids": { - "description": "Arr episode ids this candidate was expected to satisfy", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "group": { - "description": "active | issues | exhausted | resolved", - "type": "string" - }, - "health_state": { - "description": "the closed item-health grammar", - "type": "string" - }, - "media_type": { - "type": "string" - }, - "observed_at": { - "type": "string" - }, - "owner": { - "type": "string" - }, - "poster_ref": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "resolved_at": { - "type": "string" - }, - "retry_count": { - "format": "int64", - "type": "integer" - }, - "source": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "candidate_key", - "group", - "health_state", - "source", - "tmdb_id", - "media_type", - "arr", - "retry_count", - "observed_at" - ], - "type": "object" - }, - "TriageDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TriageDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "candidate": { - "$ref": "#/components/schemas/TriageCandidate" - }, - "history": { - "items": { - "$ref": "#/components/schemas/TriageHistoryEvent" - }, - "type": [ - "array", - "null" - ] - }, - "payload_sanitize_block": { - "$ref": "#/components/schemas/PayloadSanitizeBlockView" - } - }, - "required": [ - "candidate", - "history" - ], - "type": "object" - }, - "TriageHistoryEvent": { - "additionalProperties": false, - "properties": { - "at": { - "type": "string" - }, - "from_state": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "to_state": { - "type": "string" - } - }, - "required": [ - "to_state", - "at" - ], - "type": "object" - }, - "TriageResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TriageResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "candidates": { - "items": { - "$ref": "#/components/schemas/TriageCandidate" - }, - "type": [ - "array", - "null" - ] - }, - "group": { - "description": "the requested group filter (empty = all)", - "type": "string" - } - }, - "required": [ - "candidates" - ], - "type": "object" - }, - "ViewerReadiness": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ViewerReadiness.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "blocking_stage": { - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "coverage": { - "description": "Recorded-file coverage of the requested seasons (or every listed season when none were recorded); empty for a movie", - "items": { - "$ref": "#/components/schemas/SeasonCoverage" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "open_link": { - "$ref": "#/components/schemas/OpenLink" - }, - "seerr": { - "$ref": "#/components/schemas/SeerrObservation" - }, - "sources": { - "items": { - "$ref": "#/components/schemas/EvidenceSource" - }, - "type": [ - "array", - "null" - ] - }, - "stages": { - "items": { - "$ref": "#/components/schemas/ReadinessStage" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "viewer": { - "$ref": "#/components/schemas/Person" - } - }, - "required": [ - "tmdb_id", - "media_type", - "viewer", - "stages", - "seerr", - "coverage", - "open_link", - "sources" - ], - "type": "object" - }, - "WatchConfigBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/WatchConfigBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "status": { - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "WatchStateRequest": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/WatchStateRequest.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "episode_key": { - "description": "SxxEyy for a tv episode; empty for a movie", - "type": "string" - }, - "played": { - "type": "boolean" - }, - "user": { - "description": "jellyfin username (must be covered by the item's sees: matrix)", - "type": "string" - } - }, - "required": [ - "user", - "played" - ], - "type": "object" - }, - "WatchStateResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/WatchStateResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "type": "string" - }, - "played": { - "description": "the read-back value", - "type": "boolean" - }, - "status": { - "description": "ok | forbidden | no_controller | missing | error", - "type": "string" - } - }, - "required": [ - "status", - "played" - ], - "type": "object" - }, - "WebhookAck": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/WebhookAck.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "description": "Human-readable context", - "type": "string" - }, - "status": { - "description": "accepted | ignored | test | error", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - } - }, - "securitySchemes": { - "authentikSession": { - "description": "Existing Authentik forward-single proxy session cookie, verified against the configured outpost. Caller identity/role headers are never trusted.", - "in": "header", - "name": "Cookie", - "type": "apiKey" - }, - "jellyfinToken": { - "description": "Jellyfin access token: MediaBrowser Token=\"\u003c32 hex\u003e\". Verified per request against the configured Jellyfin GET /Users/Me; the immutable user id maps to a configured household person. Household authority only, TV-allowlisted operations only. Inert unless server.cockpit.jellyfin_sign_in.enabled.", - "in": "header", - "name": "Authorization", - "type": "apiKey" - } - } - }, - "info": { - "description": "The tailored media bundle: supervisor, dispatch, layer, watch-state, recommendations, curation, backfill.", - "title": "conductarr", - "version": "0.1.0-dev" - }, - "openapi": "3.1.0", - "paths": { - "/api/v1/activity": { - "get": { - "description": "Defaults to requests attributed to the signed-in operator; requester=all shows household progress. Items carry the recorded requester and actor exactly as filed, so an on-behalf request belongs to its requester. Stages are separate evidence from separate owners and are never collapsed into a ready flag: a request receipt, an Arr file, a library membership or Seerr availability each prove only their own stage, and unknown, stale, conflicting or failed evidence stays in its stage. The feed reads local ledgers and cached authenticated server playback admission only; no provider is called per row. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-activity", - "parameters": [ - { - "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", - "explode": false, - "in": "query", - "name": "requester", - "schema": { - "description": "Household requester key to follow, or all for household progress. Defaults to the signed-in operator's own requests. This selects whose requests are shown; it never changes who is acting", - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActivityFeed" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Recent requests with their request, acquisition, visibility and playback stages" - } - }, - "/api/v1/activity/titles/{tmdb}/{type}": { - "get": { - "description": "Reads Seerr request status, the holding Arr and a viewer-scoped Jellyfin view for this one title. Each live source fails independently and degrades only its own stage; a Seerr, Arr or Jellyfin outage leaves its stage stale or unknown and never proves readiness. Playback joins cached Jellyfin PlaybackInfo queried with the server key on the viewer's behalf, using a separate request pool sized by the layer visibility item_budget, with watch records and media-health faults as fallback. Items without a recorded Video stream in the first source and .strm paths are not sampled because Jellyfin can refresh metadata and inspect media for them. Admission proves neither playback policy, phone compatibility nor media bytes. open_link is the viewer's own Jellyfin item page, offered only after the viewer-scoped read found that item and only for a viewer the operator may open Jellyfin as; it carries no credential. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-activity-title", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - }, - { - "description": "Household member to read readiness for. Defaults to the signed-in operator", - "explode": false, - "in": "query", - "name": "viewer", - "schema": { - "description": "Household member to read readiness for. Defaults to the signed-in operator", - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ViewerReadiness" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "One title's viewer readiness with live Seerr, Arr and Jellyfin evidence and the Jellyfin hand-off" - } - }, - "/api/v1/home/resume": { - "get": { - "description": "Read-only Jellyfin metadata: at most twelve resume items and one parent-series batch, bounded to five seconds. No caller-selected viewer, media-source lookup or playback write. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-home-resume", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResumeBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Unfinished titles for the authenticated viewer" - } - }, - "/api/v1/operator": { - "get": { - "description": "Identity is verified from the existing Authentik proxy session or, when enabled, the caller's Jellyfin token. Roles and targets come only from server configuration, never identity headers, requester bodies or selected profiles. Jellyfin sign-in grants household-only authority without library-management powers. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-cockpit-operator", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Operator" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik or Jellyfin sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - }, - { - "jellyfinToken": [] - } - ], - "summary": "Authenticated cockpit operator and household authority" - } - }, - "/api/v1/recommendations/profiles": { - "get": { - "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-recommendation-profiles", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationProfiles" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Configured household recommendation read profiles" - } - }, - "/api/v1/recommendations/profiles/{profile}/dismissals": { - "get": { - "description": "Read-only durable list, newest first. Like the feed read it is not limited to the operator's own profile. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-recommendation-dismissals", - "parameters": [ - { - "in": "path", - "name": "profile", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationDismissals" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Titles hidden from one profile's personalised feed" - }, - "post": { - "description": "Durable until an explicit restore. Requires the profile in the operator's recommendation_profiles capability. The title leaves the retained feed, its watched-seed and service projections and the profile's Jellyfin recommendation folders; it is never requested, and real media, general search and watch-state are untouched. Repeating the command is a no-op. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "post-recommendation-dismissal", - "parameters": [ - { - "in": "path", - "name": "profile", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationHideInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationHideResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Hide a title from one profile's personalised feed" - } - }, - "/api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}": { - "delete": { - "description": "Requires the profile in the operator's recommendation_profiles capability. The title is eligible again at the next ranking cycle under the same scorer and filters; it is not inserted into any feed and not requested. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "delete-recommendation-dismissal", - "parameters": [ - { - "in": "path", - "name": "profile", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - }, - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationRestoreResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Restore a hidden title's eligibility for one profile" - } - }, - "/api/v1/recommendations/profiles/{profile}/feed": { - "get": { - "description": "Read-only in-memory observation; current expires at the configured feed interval. Ranking and Jellyfin materialization are independent. Unavailable means no successful ranking observation; stale preserves the last known result after expiry or failed refresh. Private conditional reads authenticate first and hash the complete retained projection plus operator authority and filters; matching If-None-Match returns a bodyless 304. This saves repeat transfer, not projection or hashing work. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-recommendation-feed", - "parameters": [ - { - "description": "Private display validator from a prior authenticated feed read", - "in": "header", - "name": "If-None-Match", - "schema": { - "description": "Private display validator from a prior authenticated feed read", - "type": "string" - } - }, - { - "in": "path", - "name": "profile", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Typed contributing watched seed; requires seed_tmdb_id", - "explode": false, - "in": "query", - "name": "seed_media_type", - "schema": { - "description": "Typed contributing watched seed; requires seed_tmdb_id", - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - }, - { - "description": "Contributing watched seed identity; requires seed_media_type", - "explode": false, - "in": "query", - "name": "seed_tmdb_id", - "schema": { - "description": "Contributing watched seed identity; requires seed_media_type", - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Observed candidate genre; filters retained rank without rescoring", - "explode": false, - "in": "query", - "name": "genre_id", - "schema": { - "description": "Observed candidate genre; filters retained rank without rescoring", - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Retained candidate type scope; never a new discovery query", - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "description": "Retained candidate type scope; never a new discovery query", - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - } - }, - { - "description": "Observed NL flatrate provider ID; unknown candidate providers remain unresolved", - "explode": false, - "in": "query", - "name": "service_id", - "schema": { - "description": "Observed NL flatrate provider ID; unknown candidate providers remain unresolved", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationFeed" - } - } - }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated display representation unchanged; reuse the private cached feed" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error, including 404 for an unknown profile and 422 for invalid input" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Latest known native ranked recommendation feed" - } - }, - "/api/v1/recommendations/sync": { - "post": { - "description": "Waits for any active scheduled cycle, then resolves, scores, materializes, and read-back verifies every configured profile feed. Administrative cockpit authority required.", - "operationId": "post-recommendations-sync", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecommendationSyncResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Run a complete recommendation feed cycle" - } - }, - "/api/v1/requests": { - "post": { - "description": "Files the request for the chosen household requester (default: the signed-in operator) through the non-admin Seerr on-behalf path, or, for media a household library already holds, adds the requester's membership without a second grab. The acting operator is recorded separately from the requester. Missing selected seasons stay requestable; a season is excluded only when its own files are complete or an active Seerr request for the same requester already covers it. Duplicate, lost or inconclusive Seerr responses are reconciled against Seerr's own request list: unproven coverage returns a pending, unresolved receipt, and resubmitting the same command resumes it before filing anything. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "post-request-intent", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestIntentBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestReceipt" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Request a movie or explicit television seasons for a household requester" - } - }, - "/api/v1/requests/titles/{tmdb}/{type}": { - "get": { - "description": "Read-only receipts: acting operator, requester, outcomes per season, and the Seerr and dispatch receipts each command produced. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-title-requests", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleRequestsBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Native request records for one typed title" - } - }, - "/api/v1/requests/{id}/retire": { - "post": { - "description": "Administrator-only local closeout after a fresh Seerr identity read proves the pending record no longer matches its household target. Preserves attribution and effect evidence. Does not cancel any filed Seerr request, undo membership, settle recommendations or submit another request. Unchanged identities, settled/retired records and concurrent title operations conflict. Administrative cockpit authority required.", - "operationId": "post-retire-request", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestReceipt" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Retire an exact stale-identity pending native request record" - } - }, - "/arr/webhook": { - "post": { - "description": "Download and non-upgrade EpisodeFileDelete (Sonarr) or MovieFileDelete (Radarr) events are inserted into webhook_dedup and acknowledged immediately. File-delete events only re-materialize the title because a deleted Arr file otherwise leaves a dangling per-file Jellyfin layer link. Test events and other event types are ignored. Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", - "operationId": "post-arr-webhook", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookAck" - } - } - }, - "description": "OK" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Bundled-service caller required" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "summary": "Sonarr/Radarr primary intake webhook (insert + 200; worker materializes)" - } - }, - "/arr/webhook/mirror": { - "post": { - "description": "Download and non-upgrade EpisodeFileDelete (Sonarr) or MovieFileDelete (Radarr) events are inserted into webhook_dedup and acknowledged immediately. The worker only re-materializes existing memberships; file deletes prune dangling per-file Jellyfin layer links. Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", - "operationId": "post-arr-mirror-webhook", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookAck" - } - } - }, - "description": "OK" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Bundled-service caller required" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "summary": "Sonarr/Radarr mirror refresh webhook (insert + 200; worker refreshes)" - } - }, - "/auth/login": { - "get": { - "description": "Returns to the configured cockpit origin only; caller-supplied redirect destinations are not accepted. The existing reverse proxy routes /outpost.goauthentik.io to Authentik.", - "operationId": "get-cockpit-login", - "responses": { - "302": { - "description": "Found", - "headers": { - "Location": { - "schema": { - "type": "string" - } - } - } - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "summary": "Enter the existing Authentik proxy sign-in flow" - } - }, - "/catalogue/discover": { - "get": { - "description": "General-interest provider suggestions, not personalised Conductarr recommendations. Browsing cannot request or acquire media. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-discover", - "parameters": [ - { - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "all", - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "maximum": 500, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "TMDB genre ID in the selected movie or TV namespace; zero means no genre constraint", - "explode": false, - "in": "query", - "name": "genre_id", - "schema": { - "description": "TMDB genre ID in the selected movie or TV namespace; zero means no genre constraint", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Configured TMDB streaming-provider ID; zero means no service constraint", - "explode": false, - "in": "query", - "name": "service_id", - "schema": { - "description": "Configured TMDB streaming-provider ID; zero means no service constraint", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Requires movie or TV. Popular sorts by popularity; New by primary release date (movies) or first air date (TV), capped inclusively at today's UTC date, not service arrival; Top by vote average with at least 200 TMDB votes; Upcoming uses Seerr's premiere discovery from today's UTC date, ordered by popularity", - "explode": false, - "in": "query", - "name": "sort", - "schema": { - "description": "Requires movie or TV. Popular sorts by popularity; New by primary release date (movies) or first air date (TV), capped inclusively at today's UTC date, not service arrival; Top by vote average with at least 200 TMDB votes; Upcoming uses Seerr's premiere discovery from today's UTC date, ordered by popularity", - "enum": [ - "popular", - "new", - "top", - "upcoming" - ], - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "General Seerr discovery" - } - }, - "/catalogue/options": { - "get": { - "description": "Read-only cached genre and configured regional streaming-provider options. Independent source observations distinguish answered empty lists from unavailable metadata. No title/lane preload, personalised ranking or availability claim. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-options", - "parameters": [ - { - "explode": false, - "in": "query", - "name": "media_type", - "required": true, - "schema": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Options" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Media-type genres and curated streaming services" - } - }, - "/catalogue/performers/{performer}": { - "get": { - "description": "Read-only Seerr person facts with strict performer identity. Observed identity with empty optional facts differs from unavailable or explicitly missing metadata. Seerr lookup failures, including unknown people, return unavailable, not verified absence. No title holding, request, visibility or playback claim follows from person metadata. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-performer", - "parameters": [ - { - "in": "path", - "name": "performer", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PerformerDetail" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Selected cast performer identity and biography" - } - }, - "/catalogue/performers/{performer}/filmography": { - "get": { - "description": "Read-only native pages of Seerr's unpaged non-adult cast credits, not an exhaustive career. Groups movie/series identities while preserving every role in returned order. Counts describe this source snapshot and are absent on failure. Household holding, configured audience and incomplete request relations remain independent; none proves playback. No per-credit detail fetch or mutation. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-filmography", - "parameters": [ - { - "in": "path", - "name": "performer", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "maximum": 1000000, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "page_size", - "schema": { - "default": 20, - "format": "int64", - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FilmographyPage" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Selected performer's typed acting filmography" - } - }, - "/catalogue/search": { - "get": { - "description": "Authenticated read-only catalogue search. Request, local holding and provider availability are separate observations; failed provider reads are unavailable, never fabricated empty results. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-search", - "parameters": [ - { - "explode": false, - "in": "query", - "name": "q", - "required": true, - "schema": { - "maxLength": 300, - "minLength": 1, - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "all", - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "maximum": 500, - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Native Seerr movie and series search" - } - }, - "/catalogue/titles/{tmdb}/{type}": { - "get": { - "description": "Reads provider metadata and the existing held-title owner independently. Unavailable metadata never erases known held files or implies a title is unheld. No request, membership, watched-state or acquisition writer is called. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-title", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Detail" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Native movie or series title workspace" - } - }, - "/catalogue/titles/{tmdb}/{type}/cast": { - "get": { - "description": "Read-only unpaged Seerr cast with performer IDs distinct from typed title IDs. Series expose only the leading aggregate character per performer. Missing, unavailable and observed empty cast differ. Cast metadata proves neither holding, requests, visibility nor playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-cast", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleCast" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Movie or series cast performers" - } - }, - "/catalogue/titles/{tmdb}/{type}/display": { - "get": { - "description": "Authenticated Seerr metadata read for visible request posters. Contains no holding, files, mirrors, watch state, request status or ratings. Missing or unavailable artwork says nothing about a request or media availability. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-display", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Display" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Public title presentation" - } - }, - "/catalogue/titles/{tmdb}/{type}/enrichment": { - "get": { - "description": "Authenticated fresh Seerr metadata and ratings for an already observed workspace or a visible hero. Reads no Arr, files, mirrors, memberships or watch state. Local holding and coverage remain unknown; this response never authorizes a title action. Metadata, providers and ratings preserve their independent unavailable states. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-enrichment", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Enrichment" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Title metadata and source ratings" - } - }, - "/catalogue/titles/{tmdb}/{type}/metadata": { - "get": { - "description": "Reuses bounded observations from committed title metadata reads, restored from SQLite and served from memory, with zero upstream or holding acquisition in this request. A single paced background fill observes held titles; cold and evicted titles remain unobserved until admitted. Restart preserves committed facts, their age and failure outcomes. States describe the latest attempt; timestamps identify last-known genre/provider facts, not a freshness guarantee. Provider observation requires an explicit NL result: empty or NL-absent regional results are ambiguous and preserve prior offers/timestamps as unobserved, not complete coverage. NL offers are Seerr-reported regional catalogue metadata, not subscriptions, membership, request eligibility or playback proof. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-metadata", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleMetadata" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Retained title genres and NL streaming offers" - } - }, - "/catalogue/titles/{tmdb}/{type}/ratings": { - "get": { - "description": "Authenticated ratings-only display read with the same source observations as enrichment. Server cache: 6 hours for positive results, 1 minute for missing results, at most 300 typed title identities; concurrent identical reads share a fetch. Provider failures are not cached. Expired entries are never served. Reads no Arr, files, mirrors, memberships or watch state and never authorizes a title action. Enrichment and committed title reads remain fresh. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-catalogue-ratings", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleRatings" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Cached title source ratings" - } - }, - "/catalogue/titles/{tmdb}/{type}/related": { - "get": { - "description": "Typed title-related provider suggestions kept separate from Conductarr's personalised ranking. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-related", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "page", - "schema": { - "default": 1, - "format": "int64", - "maximum": 500, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Related-title Seerr suggestions" - } - }, - "/catalogue/tv/{tmdb}/seasons/{season}": { - "get": { - "description": "Reads an explicitly selected provider season and joins local primary episode evidence without requesting missing seasons or claiming playback. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-catalogue-season", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "season", - "required": true, - "schema": { - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SeasonDetail" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Selected television season metadata and recorded coverage" - } - }, - "/curation/items": { - "get": { - "description": "Every enabled PRIMARY arr of the media type's kind joined with its bucket assignment, mapped to an owner (Justin/Mindel/Shared/Unassigned). The anime badge is SERVER-derived from the holding instance's is_anime flag (rename-proof). An arr that fails to list omits its items, never the whole view. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-curation-items", - "parameters": [ - { - "description": "tv | movie (the TV/Movies toggle)", - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "tv", - "description": "tv | movie (the TV/Movies toggle)", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ItemsBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Poster-grid items + owner-rail counts for one media type" - } - }, - "/curation/items/{tmdb}/tv/episodes": { - "get": { - "description": "The drill-in panel: seasons/episodes joined from the holding sonarr (SxxEyy, title, air date, monitored, has-file, per-episode file quality/size/release) + per-user Jellyfin watch chips. Users included are EXACTLY those whose sees: matrix covers the item's library (server-derived, owners-not-buckets). One episodes call + one files call + one Jellyfin list per user — never per-episode. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-curation-episodes", - "parameters": [ - { - "description": "TMDB id of the series", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the series", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EpisodeView" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Season-grouped episodes + per-user watch state for a TV item" - } - }, - "/curation/items/{tmdb}/tv/fill-gaps": { - "post": { - "description": "Lists the holding sonarr's episodes ONCE, filters to the gaps (monitored, without a file, and already aired — unaired/future and unmonitored episodes excluded), and issues a single EpisodeSearch for their ids. Shares the one-in-flight-per-series guard with regrab (a second answers status=busy). Response carries searched_count; 0 with status=ok means there were no gaps (a quiet no-op). Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-curation-fill-gaps", - "parameters": [ - { - "description": "TMDB id of the series", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the series", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FillGapsResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Search the gap episodes of a TV series (monitored + fileless + aired)" - } - }, - "/curation/items/{tmdb}/{type}": { - "get": { - "description": "The always-open inspector's payload for a single tile (design §2.3): Identity (title/year/runtime/owners), File (size + media info), Source (arr/imported/indexer), Health (has-file/queued/failed/missing). Fetched ONLY on selection — the targeted per-item File/Source/Health reads never run across the grid (library-scale rule). The grid list carries owners + anime already; this adds the drill-in facts. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-curation-item-detail", - "parameters": [ - { - "description": "TMDB id of the focused item", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the focused item", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ItemDetail" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Inspector detail for one focused item" - } - }, - "/curation/items/{tmdb}/{type}/delete": { - "post": { - "description": "Requires the scope_id of the deletion impact the operator confirmed. The current scope is observed again under the title lock; a different scope answers conflict and an unobservable one inconclusive, both before any effect. Only the confirmed primary and mirror records are FULL-removed (deleteFiles=true so the arr's own stale library symlink goes too; removeFromClient is NEVER sent — that GCs shared NzbDAV blobs the layer points at). The confirmed bucket-layer symlink(s) are pruned, the bucket_memberships rows dropped, and a targeted Jellyfin refresh issued. A mirror copy that cannot be removed after the primary is gone answers partial, never ok. Works for ANY provenance incl. usenet-streamed (the arr's on-disk file is its own symlink; the NzbDAV import tree is never touched). Bulk delete is a client-side fan-out over this endpoint — one confirmed scope and one result per item. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-curation-delete", - "parameters": [ - { - "description": "TMDB id of the item to delete", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the item to delete", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Delete one library item across all provenance (R12) within its confirmed scope" - } - }, - "/curation/items/{tmdb}/{type}/deletion-impact": { - "get": { - "description": "Read-only observation of the existing deletion owner's primary and rule-derived mirrors, exact membership and managed layer-link effects (including orphan collateral), and configured libraries/viewers. ready supplies the scope the operator confirms: its scope_id binds a later deletion request, which observes the scope again and deletes nothing when it differs. It is not a reservation or authorization. missing and inconclusive never supply a scope; unavailable reads cannot prove absence. Global deletion removes the confirmed Arr records with deleteFiles=true, prunes managed links and emptied directories, reconciles memberships/dispatch and requests a best-effort Jellyfin refresh. It never removes download-client payloads, blocklists, deletes NzbDAV blobs or erases watched state. Separate membership Remove is non-destructive. This read changes nothing. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-curation-deletion-impact", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeletionImpact" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Observe exact global media deletion impact without changing media" - } - }, - "/curation/items/{tmdb}/{type}/regrab": { - "post": { - "description": "Blocklists the grabbed release(s) (POST history/failed) + clears any queued grab (DELETE queue, removeFromClient=FALSE always, blocklist=true) then issues the replacement search (EpisodeSearch/SeasonSearch/MoviesSearch). Never touches the import tree; the arr replaces the file on the new import. One in-flight regrab per item (shared soak account) — a second answers status=busy. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-curation-regrab", - "parameters": [ - { - "description": "TMDB id of the item", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the item", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegrabRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegrabResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Regrab episodes/season (tv) or the movie (R12 regrab, pulled forward)" - } - }, - "/curation/items/{tmdb}/{type}/watchstate": { - "post": { - "description": "Marks one user's episode (SxxEyy) or movie played/unplayed with a LIVE Jellyfin write, verified by read-back. Refuses a user not covered by the item's sees: matrix (status=forbidden). This is a direct operator write — NOT the capture/restore machinery — so it never touches the clobber guard. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "post-curation-watchstate", - "parameters": [ - { - "description": "TMDB id of the item", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the item", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WatchStateRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WatchStateResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Direct operator watch-state toggle (live Jellyfin write + read-back)" - } - }, - "/curation/layer/movie-cutover": { - "get": { - "description": "Lists legacy movie links and started or failed cutovers that have a durable path plan and watch snapshot. RecoveryCutoverID identifies a bracket that can be resumed by posting the same TMDB id even if no legacy links remain. Administrative cockpit authority required.", - "operationId": "get-curation-layer-movie-cutover", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MovieCutoverPendingBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "List legacy movies and interrupted movie cutovers" - } - }, - "/curation/layer/movie-cutover/{tmdb}": { - "get": { - "description": "Read-only evidence, including a retained bracket after legacy links disappear. Snapshot and restored counts are durable reported counts, not a new capture or live playback proof. The existing POST re-observes scope and preserves its capture/restore policy. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-title-movie-cutover", - "parameters": [ - { - "description": "TMDB id of the movie to convert", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the movie to convert", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MovieCutoverEvidence" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read one movie's legacy links and latest durable cutover" - }, - "post": { - "description": "A fresh cutover requires an exact indexed Movie at each old primary path for every relevant viewer, then persists the planned old-to-new paths and per-user snapshot before converting any entry. Re-posting an unfinished bracket re-indexes the planned new paths and field-wise merges the durable snapshot with current live UserData only for indexed targets. Partial indexing restores the reachable subset and reports unresolved paths for another retry; any still-legacy entries need a fresh capture before conversion. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-curation-layer-movie-cutover", - "parameters": [ - { - "description": "TMDB id of the movie to convert", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the movie to convert", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MovieCutoverBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Convert or safely resume one movie cutover" - } - }, - "/curation/library": { - "get": { - "description": "Unpaged retained-metadata filtering. Items are confirmed matches; unresolved contains possible matches, never known exclusions. Coverage counts the entire named/my/all, type and text scope before genre/service/watched predicates, not a rendered page. Counts and source statistics cover the captured media-type collection. Source observation time is explicit; membership and configured viewers are rejoined on every read. A snapshot reuses the last collection across scopes; omit it for a fresh capture. Watched filtering indexes only the verified viewer's collection once per snapshot. Facts retain independent latest genre/provider outcomes and last-success timestamps: no TTL or freshness guarantee. Committed metadata survives restart; cold, evicted and capacity-overflow identities remain unobserved until admitted. A single paced background fill observes held titles; this request never hydrates metadata. Filter completeness is not complete metadata coverage, subscription, request eligibility or playback proof. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-household-library", - "parameters": [ - { - "description": "Title or year search across household libraries by default", - "explode": false, - "in": "query", - "name": "q", - "schema": { - "description": "Title or year search across household libraries by default", - "maxLength": 300, - "type": "string" - } - }, - { - "description": "my/all (reserved), an enabled exact library display, or a legacy owner/Unassigned label; configured named libraries take precedence over owner labels", - "explode": false, - "in": "query", - "name": "library", - "schema": { - "description": "my/all (reserved), an enabled exact library display, or a legacy owner/Unassigned label; configured named libraries take precedence over owner labels", - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "all", - "enum": [ - "all", - "tv", - "movie" - ], - "type": "string" - } - }, - { - "description": "Observed genre ID in each title's movie/TV namespace; zero disables", - "explode": false, - "in": "query", - "name": "genre_id", - "schema": { - "description": "Observed genre ID in each title's movie/TV namespace; zero disables", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Observed NL flatrate provider ID; zero disables", - "explode": false, - "in": "query", - "name": "service_id", - "schema": { - "description": "Observed NL flatrate provider ID; zero disables", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Verified operator's Jellyfin Played flag, never a submitted viewer identity", - "explode": false, - "in": "query", - "name": "watched", - "schema": { - "default": "all", - "description": "Verified operator's Jellyfin Played flag, never a submitted viewer identity", - "enum": [ - "all", - "watched", - "unwatched" - ], - "type": "string" - } - }, - { - "description": "Reuse the last retained collection without Arr reads; omitted captures anew, replaced or restarted snapshots return 409", - "explode": false, - "in": "query", - "name": "snapshot", - "schema": { - "description": "Reuse the last retained collection without Arr reads; omitted captures anew, replaced or restarted snapshots return 409", - "type": "string" - } - }, - { - "description": "Force a new Arr acquisition instead of the shared one taken within the last few seconds; ignored with a snapshot", - "explode": false, - "in": "query", - "name": "refresh", - "schema": { - "description": "Force a new Arr acquisition instead of the shared one taken within the last few seconds; ignored with a snapshot", - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Unified held title search and configured household visibility" - } - }, - "/curation/library-audiences": { - "get": { - "description": "Projects named membership routes, enabled Jellyfin libraries and users' configured sees by media type and anime scope. Includes empty libraries and scopes with no configured viewer. This read does not inspect current title unions, watch-write permissions or live media, and grants no mutation authority. An unwired audience read is unavailable, never an empty preview. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers. Conditional reads authenticate and authorize first. ETag hashes verified operator authority, request identity and exact serialized representation; matching If-None-Match returns bodyless 304. Cache-Control: no-store remains unchanged: HTTP caches must not store the response or validator. An application-owned, person-partitioned last-known-good display model may be revalidated explicitly; validators never permit cross-person reuse. Vary includes Cookie and Authorization. Reads, projection, encoding and hashing still run on 304; only repeat transfer is avoided.", - "operationId": "get-library-audiences", - "parameters": [ - { - "description": "Validator from this operator's prior application-owned display representation; weak tags, lists and * use GET weak comparison", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryAudiencesBody" - } - } - }, - "description": "OK", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Authenticated representation unchanged; no body. Reuse only the same person's application-owned display state.", - "headers": { - "ETag": { - "description": "Strong SHA-256 validator scoped to verified operator authority, request identity and exact response bytes", - "schema": { - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Configured viewing audience for every enabled household membership scope" - } - }, - "/curation/library/activity": { - "get": { - "description": "Acquired separately from the title collection so a slow queue or history read never delays title presence. Each source reports whether its queue and history answered; a count is only meaningful for sources that did, and an unanswered source is never a zero. Per-source read times are explicit and may predate the request by the shared-acquisition window; refresh forces a new read. Administrative cockpit authority required.", - "operationId": "get-library-activity", - "parameters": [ - { - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "all", - "enum": [ - "all", - "tv", - "movie" - ], - "type": "string" - } - }, - { - "description": "Force a new queue and history read instead of the shared one taken within the last few seconds", - "explode": false, - "in": "query", - "name": "refresh", - "schema": { - "description": "Force a new queue and history read instead of the shared one taken within the last few seconds", - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryActivity" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Queue depth and import history of the primaries serving a media type" - } - }, - "/curation/library/{tmdb}/{type}": { - "get": { - "description": "Reads one movie or series identity, its canonical household memberships/viewers, Arr-recorded primary and mirror files, and the existing watched-state interface. A missing title requires every relevant primary to answer; unavailable or partially answered evidence is not missing media or playback proof. Reads are performed only after explicit detail navigation, never pointer hover or bulk selection. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-held-title", - "parameters": [ - { - "description": "TMDB id of the focused item", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the focused item", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "in": "path", - "name": "type", - "required": true, - "schema": { - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HeldTitle" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Committed held title workspace with seasons, versions and configured viewers" - } - }, - "/curation/membership-jobs": { - "get": { - "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-curation-memberships", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipJobsBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read recent membership commands" - } - }, - "/curation/memberships": { - "post": { - "description": "Validates the unchanged admission rules and durably queues the change without waiting on Arr or Jellyfin. The existing intake worker applies watch-state capture, materialization, convergence and read-back. Poll the returned receipt ID for per-title outcomes; acceptance is not completion. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "post-curation-memberships", - "parameters": [ - { - "description": "Reuse this key only for the same confirmed membership change", - "in": "header", - "name": "Idempotency-Key", - "schema": { - "description": "Reuse this key only for the same confirmed membership change", - "maxLength": 80, - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipInputBody" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipJob" - } - } - }, - "description": "Accepted" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Dispatch a curation membership change" - } - }, - "/curation/memberships/{id}": { - "get": { - "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-curation-membership", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MembershipJob" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read membership command completion" - } - }, - "/curation/rename/{tmdb}": { - "get": { - "description": "Sonarr's own GET /api/v3/rename for the holding primary: every episode file whose on-disk name differs from what the naming config builds, with its existing and new series-folder-relative path. Changes nothing. An EMPTY files list means the series is already converged, and the POST will refuse. 503 while arr_naming.rename_episodes is false. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-curation-rename", - "parameters": [ - { - "description": "TMDB id of the series to rename (television only)", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the series to rename (television only)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenamePlanBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Preview the Sonarr rename pass for one series" - }, - "post": { - "description": "Renames ONE series' episode files to the naming config's canonical names and carries its watch-state across: durable scoped snapshot plus a same-operation capture of every user who sees the item's buckets, Sonarr's RenameFiles command over exactly the previewed episodeFileIds (waited to a terminal status), per-file layer links repointed and renamed where a bucket entry is a real directory rather than a whole-title symlink, a Jellyfin notify + structural validate + bounded re-index poll, then the capture remapped onto the new item ids BY PATH and restored through the live-wins merge with read-back. Refuses an empty preview (409) and never runs automatically. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-curation-rename", - "parameters": [ - { - "description": "TMDB id of the series to rename (television only)", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the series to rename (television only)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenameResultBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Run the Sonarr rename pass for one series" - } - }, - "/curation/rename/{tmdb}/passes": { - "get": { - "description": "Newest first, including unfinished passes, independently of preview availability. Files is a planned count, not actual renames. Restored and repointed counts are retained; skipped files, convergence and the full response are not. A retained request_id matches the originating POST's optional X-Request-ID transport metadata; absent or different identities cannot prove that original finished. This is not idempotency or a reconstructed response. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-curation-rename-passes", - "parameters": [ - { - "description": "TMDB id of the series to rename (television only)", - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "description": "TMDB id of the series to rename (television only)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RenameHistoryOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read all durable rename passes for one television title" - } - }, - "/curation/watch-config": { - "post": { - "description": "Operator override for the boot-seeded watch guard. Resolves only enabled config Jellyfin usernames to runtime ids and atomically rewires reassign watch-state preservation. Boot retries this wiring while Jellyfin is unavailable. Answers 503 until serve supplies the resolver. Administrative cockpit authority required.", - "operationId": "post-curation-watch-config", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WatchConfigBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Rewire the reassign watch-guard to enabled Jellyfin users" - } - }, - "/dispatch/dry-run": { - "get": { - "description": "Returns the rule, routed primary arr and rule-derived mirror instances an add would fan out to. No side effects. Administrative cockpit authority required.", - "operationId": "get-dispatch-dry-run", - "parameters": [ - { - "description": "TMDB id to resolve", - "explode": false, - "in": "query", - "name": "tmdb_id", - "required": true, - "schema": { - "description": "TMDB id to resolve", - "format": "int64", - "type": "integer" - } - }, - { - "description": "tv | movie", - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "default": "tv", - "description": "tv | movie", - "enum": [ - "tv", - "movie" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DryRunPlan" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Resolve routing for an item without adding (engine.Plan as data)" - } - }, - "/dispatch/missing": { - "get": { - "description": "Compares each dispatch_log success against live arr state INCLUDING rule-derived mirror membership. An item deleted from an arr after its success row is surfaced here — the forever-dedup would otherwise never retry it. Administrative cockpit authority required.", - "operationId": "get-dispatch-missing", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MissingOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Success rows whose item is absent from an arr (incl. rule-derived mirrors)" - }, - "post": { - "description": "Forces a re-dispatch for the given items so a mirror deleted after a success row is re-added. The primary re-add is a safe 400-already_added. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-dispatch-missing-redispatch", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RedispatchInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RedispatchOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Re-dispatch missing items (bypasses the success dedup)" - } - }, - "/dispatch/recent": { - "get": { - "description": " Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-dispatch-recent", - "parameters": [ - { - "description": "max rows (newest first)", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 50, - "description": "max rows (newest first)", - "format": "int64", - "maximum": 500, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "filter by outcome (empty = all)", - "explode": false, - "in": "query", - "name": "outcome", - "schema": { - "description": "filter by outcome (empty = all)", - "enum": [ - "success", - "partial", - "failed" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecentOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Paginated dispatch_log (newest first)" - } - }, - "/downloads": { - "get": { - "description": "Groups Arr queue records by download id so a pack is one row, joins qBittorrent by infohash, and classifies active, client-queued, delay-pending, import-blocked, and imported-awaiting-seed-cleanup states server-side. Also returns a cached library provenance snapshot (torrent, spot, manual, usenet-streamed) with bounded on-demand local-dataset usage. Primary Arrs are listed once and indexed; no per-item API calls or per-poll filesystem walks. Authenticated household cockpit authority required; watch-state writes are limited to capability-listed viewers.", - "operationId": "get-downloads", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadsBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Grouped download activity and library storage provenance" - } - }, - "/healthz": { - "get": { - "description": "HTTP 503 only when the aggregate is unhealthy (a restart may fix it); degraded states stay 200 — restart churn must not destroy healthy state.", - "operationId": "get-healthz", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthzBody" - } - } - }, - "description": "OK" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthzBody" - } - } - }, - "description": "Aggregate unhealthy (a restart may fix it)" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "summary": "Aggregate + per-module health" - } - }, - "/healthz/history": { - "get": { - "description": "Reads the health-history ring table (a clock-driven sampler snapshots the registry's cached states ~every 60s, retained 14 days) and returns, per module, the ordered per-sample state series (the supervision-panel sparkline input), the uptime% (fraction of present samples healthy), and the restart-transition count (recoveries into healthy). Window is a Go duration query param (default 24h, clamped to the 14-day retention). Purely derived from the ring — no live probing. Administrative cockpit authority required.", - "operationId": "get-healthz-history", - "parameters": [ - { - "description": "look-back window as a Go duration (e.g. 24h, 6h); clamped to the 14-day retention", - "explode": false, - "in": "query", - "name": "window", - "schema": { - "default": "24h", - "description": "look-back window as a Go duration (e.g. 24h, 6h); clamped to the 14-day retention", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HistoryReport" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Per-module health series + uptime%/restart-transitions" - } - }, - "/import-health/candidates": { - "get": { - "description": "The intake triage panel: every open candidate grouped by health (active = searching/downloading/source_complete/import_pending/retry_wait/regrab_eligible; issues = import_blocked/blocked_ambiguous; exhausted; resolved = an operator-resolved candidate). Optional group query filter. Read ONCE from the ledger — never a per-candidate query. Administrative cockpit authority required.", - "operationId": "get-import-health-candidates", - "parameters": [ - { - "description": "triage group filter (empty = all groups)", - "explode": false, - "in": "query", - "name": "group", - "schema": { - "description": "triage group filter (empty = all groups)", - "enum": [ - "active", - "issues", - "exhausted", - "resolved" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TriageResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Candidate triage grouped active/issues/exhausted/resolved" - } - }, - "/import-health/candidates/{key}": { - "get": { - "description": "The inspector's per-item health view: the candidate's current health state + reason and its full append-only transition trail. Two indexed reads (get + history). Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-import-health-candidate", - "parameters": [ - { - "description": "the candidate's idempotent key", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TriageDetail" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Current health + transition history for one candidate" - } - }, - "/import-health/candidates/{key}/abandon": { - "post": { - "description": "Resolves ONE exhausted candidate with a required non-empty operator reason (audited in the ledger). The ledger enforces exhausted-only; a non-exhausted attempt answers 409 and an empty reason answers 400. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-abandon", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AbandonInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Audited abandon of an exhausted candidate" - } - }, - "/import-health/candidates/{key}/accept-review-mapping": { - "post": { - "description": "Operator-confirmed form of the shipped confident remap. The request carries no episode id. Under the shared exact-import guard, the handler re-probes the Arr and requires exactly one file mapped to exactly one aired, monitored, fileless episode of the candidate's series with only numbering-confidence rejections. It submits that Arr episode id through the existing exact ManualImport seam and records import_pending. No queue, blocklist, file-delete, or guessed-id path is reachable. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-accept-review-mapping", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Accept the Arr's current 1:1 episode mapping" - } - }, - "/import-health/candidates/{key}/clear-payload-sanitize-block": { - "post": { - "description": "Removes ONE candidate's payload-name-sanitize refusal marker. The automatic class records a marker only when the server's answer was evidence that the rename must not be repeated, and it never re-arms itself; this is the only way back. It performs no arr call, no queue mutation, no rename, and no candidate state change — the next sweep simply re-proves eligibility from scratch and may refuse again. A candidate carrying no marker answers not_eligible. Administrative cockpit authority required.", - "operationId": "post-import-health-clear-payload-sanitize-block", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Re-arm the automatic payload-name sanitizer for one candidate" - } - }, - "/import-health/candidates/{key}/episode-options": { - "get": { - "description": "Joins ONE scoped manual-import candidate probe to ONE series episode read and marks each episode selectable only when it is in the payload, belongs to the expected series, is aired + monitored, and has no file. A probe failure returns the series rows with in_payload=false for the raw-id fallback. This read never authorizes an import; select-episodes re-verifies every posted id server-side. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-import-health-episode-options", - "parameters": [ - { - "description": "the candidate's idempotent key", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/EpisodeOption" - }, - "type": [ - "array", - "null" - ] - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Browsable advisory episode options for one TV candidate" - } - }, - "/import-health/candidates/{key}/regrab": { - "post": { - "description": "Allowed ONLY for a regrab_eligible candidate: re-searches its recorded primary Arr and exact candidate episode scope through the existing candidate curation primitive, never current membership or a whole-series fallback. Fresh recorded identity and monitored/fileless target proof precede mutation. The shared one-in-flight guard preserves removeFromClient=FALSE and skipRedownload=TRUE while blocklisting the failed release, then records the retry_wait cooldown. Any other health state returns not_eligible with the state named and never touches the queue — a usable/ambiguous payload is an import problem, not a grab problem. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-regrab", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Eligible regrab/retry through the curation regrab primitive" - } - }, - "/import-health/candidates/{key}/remove-from-queue": { - "post": { - "description": "Eligible only for import_blocked, blocked_ambiguous, or exhausted. DownloadID locates a whole queue group; it is not removal authority. With or without a DownloadID, requires the verified recorded movie or series identity and, for TV, the complete expected episode set. Every row of a located download must match that scope before any DELETE; without a DownloadID, requires one exact fallback group. Shares the regrab guard and calls only queue delete with removeFromClient structurally false; the library deleteFiles path is unreachable. then=reopen transitions to regrab_eligible; then=resolve records a same-health audited resolution. Wrong health state or insufficient/conflicting queue scope answers 409 without mutation; refresh evidence and review the full download on a scope refusal. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-remove-from-queue", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RemoveFromQueueInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Reviewed operator removal of one blocked candidate's queue slot" - } - }, - "/import-health/candidates/{key}/retry-import": { - "post": { - "description": "Re-probes the arr's manual-import read-back for ONE candidate, rebuilds the expectation from the arr's own records, re-evaluates exact, and submits ONLY a go-decision (import_pending). A still-failing result re-persists the typed reason (import_blocked/blocked_ambiguous) and issues NO import and NO regrab. Eligible only for import_blocked/blocked_ambiguous/source_complete; any other state answers 409 with the state named. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-retry-import", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Operator re-drive of a blocked/complete candidate's exact import" - } - }, - "/import-health/candidates/{key}/review-mapping": { - "get": { - "description": "Returns available=true only when the Arr currently reports exactly one payload file mapped to exactly one aired, monitored, fileless episode of the candidate's series and every blocking rejection is a numbering-confidence rejection. This endpoint is read-only. The Accept request carries no episode id; its handler re-probes and submits the Arr's current mapping server-side. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-import-health-review-mapping", - "parameters": [ - { - "description": "the candidate's idempotent key", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CandidateReviewMapping" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Arr-proven 1:1 episode mapping for operator review" - } - }, - "/import-health/candidates/{key}/select-episodes": { - "post": { - "description": "Rebuilds a TV candidate's expectation with EXACTLY the posted episode ids, re-verified against the arr's own records (each must exist, belong to the series, be monitored + aired, and have no file) — the posted ids are never trusted blind. Any cross-series / unaired / unmonitored / file-backed / unknown id rejects the whole request with no submission. On a go-decision, submits exactly those ids. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-import-health-select-episodes", - "parameters": [ - { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "in": "path", - "name": "key", - "required": true, - "schema": { - "description": "the candidate's idempotent key (exactly one candidate per call)", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SelectEpisodesInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ActionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Operator-supplied exact TV episode mapping (re-verified)" - } - }, - "/media-health/breaker/hold": { - "post": { - "description": "The only operator override of the detection breaker, and it is fail-safe by construction: it can force the breaker open (suspending proposals and repairs) and nothing else. Authenticated operator attribution and a nonempty reason are required. Holding an already-held breaker re-attributes it. No request to this API can close the breaker, credit a control epoch, or shorten the two-clean-epoch recovery. Administrative cockpit authority required.", - "operationId": "post-media-health-breaker-hold", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthBreakerHoldInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthBreakerResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Force the detector breaker OPEN under operator attribution" - } - }, - "/media-health/breaker/release": { - "post": { - "description": "Clears the hold attribution only. The breaker stays OPEN with zero healthy control epochs, so the standard two-clean-epoch recovery runs from scratch afterwards — this endpoint uses authenticated operator attribution; releasing a breaker that is not held is a no-op. Administrative cockpit authority required.", - "operationId": "post-media-health-breaker-release", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthBreakerResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Release an operator hold WITHOUT closing the breaker" - } - }, - "/media-health/probe": { - "post": { - "description": "Persists one operator-requested probe for exactly one blob. Use blob_id or webdav_path, but never both. The request survives restart, is selected before scheduled targets within the same budget, and is consumed with the next recorded probe outcome. A disabled detector answers 503. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-media-health-probe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthProbeInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthProbeResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Request ONE operator media-health probe" - } - }, - "/media-health/proposals": { - "get": { - "description": "The media-health review panel: every confirmed NzbDAV fault the detector proposed a repair for, grouped by whether it awaits a decision, is being repaired, or is finished. Each proposal carries the COMPLETE consumer snapshot, so a season pack shows every episode the repair covers. The response also reports the detector's global breaker: while it is open, an approval is recorded but no repair executes. Read once from the ledger. Administrative cockpit authority required.", - "operationId": "get-media-health-proposals", - "parameters": [ - { - "description": "group filter (empty = all groups)", - "explode": false, - "in": "query", - "name": "group", - "schema": { - "description": "group filter (empty = all groups)", - "enum": [ - "review", - "repairing", - "closed" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthReviewResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Media-fault repair proposals grouped review/repairing/closed" - } - }, - "/media-health/proposals/{id}": { - "get": { - "description": "The inspector view: the proposal's blob identity, every Arr record it backs, and the append-only stage trail of its repair (queued, deleting, searching, awaiting, verifying, done/failed) with the reason recorded at each transition. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-media-health-proposal", - "parameters": [ - { - "description": "the proposal id (exactly one proposal per call)", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "the proposal id (exactly one proposal per call)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthDetail" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "One proposal with its repair transition trail" - } - }, - "/media-health/proposals/{id}/approve": { - "post": { - "description": "Records operator consent for exactly one proposal and opens its repair at the queued stage. NO Arr call is made here: the fault-kind verifier re-probes immediately before acting and abandons the repair if its proof no longer holds. Only a pending proposal is eligible; an already decided, executing, or retracted one answers 409. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-media-health-approve", - "parameters": [ - { - "description": "the proposal id (exactly one proposal per call)", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "the proposal id (exactly one proposal per call)", - "format": "int64", - "type": "integer" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthDecisionInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthDecisionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Approve the repair of ONE media fault" - } - }, - "/media-health/proposals/{id}/reject": { - "post": { - "description": "Closes exactly one proposal with a required non-empty audited reason. No repair row is created and no Arr delete, blocklist, queue removal, search, or grab is ever issued for it. An empty reason answers 400; a non-pending proposal answers 409. The blob stays in the detector's inventory and re-surfaces only if its same fault kind is confirmed again. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-media-health-reject", - "parameters": [ - { - "description": "the proposal id (exactly one proposal per call)", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "the proposal id (exactly one proposal per call)", - "format": "int64", - "type": "integer" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthDecisionInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MediaHealthDecisionResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Reject the repair of ONE media fault" - } - }, - "/notify/recent": { - "get": { - "description": "Administrator-only notify_log feed: media_arrived, media_stalled, media_regrab, and media_import_issue pushes with the descriptor recorded at send time (title, year, owner, hint). Administrative cockpit authority required.", - "operationId": "get-notify-recent", - "parameters": [ - { - "description": "max rows (newest first)", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 15, - "description": "max rows (newest first)", - "format": "int64", - "maximum": 200, - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotifyRecentOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Recent notifications pushed to Home Assistant (newest first)" - } - }, - "/operations/titles/{tmdb}/{type}": { - "get": { - "description": "Administrative read only. Includes terminal candidates and complete retained histories. Import, dispatch and current route-presence observations fail independently; unavailable evidence never proves empty, imported or playable media. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-title-diagnosis", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleDiagnosis" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read exact retained import and dispatch evidence for a typed title" - } - }, - "/operations/titles/{tmdb}/{type}/media-health": { - "get": { - "description": "Read only. Matches any recorded consumer and retains the entire proposal scope, including other titles and mirrors. File identities are proposal snapshots, not fresh Arr proof. No probe, repair, retry or policy change occurs. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-title-media-health", - "parameters": [ - { - "in": "path", - "name": "tmdb", - "required": true, - "schema": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "in": "path", - "name": "type", - "required": true, - "schema": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TitleMediaHealth" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read complete media-health proposals and repair evidence for one typed title" - } - }, - "/reconcile/replacement-holds": { - "get": { - "description": "Lists replacement holds whose expiry is still in the future. Administrative cockpit authority required.", - "operationId": "list-replacement-holds", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplacementHoldsResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "List active operator replacement holds" - }, - "post": { - "description": "Creates or replaces a TTL-bound hold for exactly one primary Radarr movie or a set of primary Sonarr episodes. A hold prevents auto-resolve from clearing the matching Arr queue candidate; it does not import the replacement. Administrative cockpit authority required.", - "operationId": "create-replacement-holds", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateReplacementHoldInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplacementHoldsResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Hold satisfied candidate auto-resolve for a replacement grab" - } - }, - "/reconcile/replacement-holds/{arr}/{record_type}/{record_id}": { - "delete": { - "description": "Deletes the hold for one Arr record. Releasing a missing hold is an idempotent no-op. Administrative cockpit authority required.", - "operationId": "release-replacement-hold", - "parameters": [ - { - "description": "Arr instance name", - "in": "path", - "name": "arr", - "required": true, - "schema": { - "description": "Arr instance name", - "type": "string" - } - }, - { - "description": "held record type", - "in": "path", - "name": "record_type", - "required": true, - "schema": { - "description": "held record type", - "enum": [ - "episode", - "movie" - ], - "type": "string" - } - }, - { - "description": "Arr episode or movie id", - "in": "path", - "name": "record_id", - "required": true, - "schema": { - "description": "Arr episode or movie id", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplacementHoldReleaseResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Release one operator replacement hold" - } - }, - "/recovery/layer/actions": { - "get": { - "description": "Returns durable MaterializeAndRefresh proposals and execution stages. Paired tmdb_id/media_type selects complete retained title history, including terminal actions, without the global worklist limit. Approval is a separate, explicitly named POST. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits. Non-administrators must supply paired positive tmdb_id and movie/tv media_type filters; global worklists remain administrative.", - "operationId": "get-recovery-layer-actions", - "parameters": [ - { - "description": "exact title TMDB identity; requires media_type", - "explode": false, - "in": "query", - "name": "tmdb_id", - "schema": { - "description": "exact title TMDB identity; requires media_type", - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "description": "exact title type; requires tmdb_id", - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "description": "exact title type; requires tmdb_id", - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - }, - { - "description": "exact durable stage filter (empty = all)", - "explode": false, - "in": "query", - "name": "stage", - "schema": { - "description": "exact durable stage filter (empty = all)", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LayerVisibilityActionsOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read reviewed layer convergence actions" - } - }, - "/recovery/layer/actions/{action_id}": { - "get": { - "description": "Returns one stable action identity with its latest exact represented-child evidence and append-only durable stage history. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "get-recovery-layer-action", - "parameters": [ - { - "description": "stable reviewed action identity", - "in": "path", - "name": "action_id", - "required": true, - "schema": { - "description": "stable reviewed action identity", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LayerVisibilityActionOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read one layer action, exact proof, and history" - } - }, - "/recovery/layer/actions/{action_id}/approve": { - "post": { - "description": "Runs the reviewed MaterializeAndRefresh for exactly one proposed action IN THE SERVING PROCESS, and returns the stage the executor left it in. Every stage is a durable compare-and-set commit, so the trail is readable from the action route while this call is in flight. The executor re-reads fresh proof and re-confirms the content behind the item before it materialises anything: an already-converged item closes without remote work, and unreadable content aborts as execution_uncertain rather than re-advertising a broken item. An unknown id answers 404, an action that is not proposed answers 409, and an unwired executor answers 503. There is no bulk approval by construction. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits.", - "operationId": "post-recovery-layer-approve", - "parameters": [ - { - "description": "stable reviewed action identity", - "in": "path", - "name": "action_id", - "required": true, - "schema": { - "description": "stable reviewed action identity", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LayerVisibilityApprovalView" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Approve and execute ONE layer convergence action" - } - }, - "/recovery/layer/observations": { - "get": { - "description": "Returns exact Arr-to-owned-link-to-Jellyfin observations. Paired tmdb_id/media_type selects every recorded bucket for that title without the global worklist limit. Parent Series, Season, and watch metadata are not convergence proof. Library-management cockpit capability required (administrators included); this grants authority across household titles, not other viewers' watched-state or recommendation edits. Non-administrators must supply paired positive tmdb_id and movie/tv media_type filters; global worklists remain administrative.", - "operationId": "get-recovery-layer-observations", - "parameters": [ - { - "description": "exact title TMDB identity; requires media_type", - "explode": false, - "in": "query", - "name": "tmdb_id", - "schema": { - "description": "exact title TMDB identity; requires media_type", - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - { - "description": "exact title type; requires tmdb_id", - "explode": false, - "in": "query", - "name": "media_type", - "schema": { - "description": "exact title type; requires tmdb_id", - "enum": [ - "movie", - "tv" - ], - "type": "string" - } - }, - { - "description": "status filter (empty = all)", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "status filter (empty = all)", - "enum": [ - "converged", - "drift", - "scanner_pending", - "unknown", - "no_files", - "unreleased", - "needs_operator" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LayerVisibilityObservationsOutputBody" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read current layer and Jellyfin convergence evidence" - } - }, - "/recovery/spottarr/freshness": { - "get": { - "description": "Returns the cached read-only SQLite freshness observation: newest IndexedAt/UpdatedAt timestamp, age, and row count. HTTP liveness is not indexing proof. Administrative cockpit authority required.", - "operationId": "get-recovery-spottarr-freshness", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SpottarrFreshnessView" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Read cached Spottarr index freshness" - } - }, - "/seerr/webhook": { - "post": { - "description": "Classifies notification_type: MEDIA_APPROVED/MEDIA_AUTO_APPROVED are inserted into webhook_dedup and answered 200 immediately (the worker runs the dispatch engine). TEST_NOTIFICATION answers 200 and does nothing. MEDIA_DECLINED and everything else are ignored (deletions are out of scope). Restricted to direct loopback bundled-service callers without browser/session/proxy headers; no cockpit role is granted.", - "operationId": "post-seerr-webhook", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookAck" - } - } - }, - "description": "OK" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Bundled-service caller required" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "summary": "Jellyseerr approval webhook (insert + 200; worker dispatches)" - } - }, - "/supervise/restart/{target}": { - "post": { - "description": "Cycles a process-backed bundled service (nzbdav, an arr, prowlarr, seerr) via the supervisor, or restarts an allowlisted sibling container through the socket-proxy (degrading cleanly when the proxy is absent). Worker-loop modules (dispatch-sweep, webhook-worker) are NOT restartable — they ride the conductarr process — and answer 400. Administrative cockpit authority required.", - "operationId": "post-supervise-restart", - "parameters": [ - { - "description": "module or sibling name to restart", - "in": "path", - "name": "target", - "required": true, - "schema": { - "description": "module or sibling name to restart", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestartResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Restart a bundled service or managed sibling" - } - }, - "/torrent/arm": { - "post": { - "description": "Persists the armed state on the plan revision the owner read in the arm confirm; the next start pushes its pick. Answers 409 when that revision is no longer the current plan, and 503 while features.torrent_fallback is off. No restart is involved. Administrative cockpit authority required.", - "operationId": "arm-torrent-lane", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentArmInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentStatusResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Arm the torrent lane" - } - }, - "/torrent/candidates": { - "get": { - "description": "The joined candidates table of the torrent lane: every dead-file record of a repair proposal and every missing monitored record on a primary Arr, with the ledger that tracks it, its answered-silent days, whether the count was seeded from history, and whether it qualifies. Read from the lane's ledger alone; answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "get-torrent-candidates", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentCandidatesResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Torrent candidates with their silence clocks" - } - }, - "/torrent/disarm": { - "post": { - "description": "Clears the armed state: the next start is a dry run again, and pushed handoffs run to their terminal. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "disarm-torrent-lane", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentStatusResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Return the torrent lane to shadow" - } - }, - "/torrent/history": { - "get": { - "description": "Returns recorded handoffs from the lane ledger. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "get-torrent-history", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentHistoryResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Torrent handoff history" - } - }, - "/torrent/plan": { - "get": { - "description": "Returns the latest cached dry-run plan and its row decisions without issuing grabs. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "get-torrent-plan", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentPlanResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Latest torrent shadow plan" - } - }, - "/torrent/status": { - "get": { - "description": "Returns cached lane mode and handoff capacity. Answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "get-torrent-status", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentStatusResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Torrent lane shadow status" - } - }, - "/torrent/summary": { - "get": { - "description": "Sums the current plan's rows: torrent releases each acceptability rule dropped per indexer, coverage by kind and anime flag, rotation over the day-one set, usenet wins, the disk of the next starts against the floor, and whether the arming floor holds per kind. Reads persisted state only; answers 503 while features.torrent_fallback is off. Administrative cockpit authority required.", - "operationId": "get-torrent-summary", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TorrentSummaryResult" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Authentik sign-in required" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Operator or target is not permitted" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Identity verification or operation unavailable" - }, - "default": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ErrorModel" - } - } - }, - "description": "Error" - } - }, - "security": [ - { - "authentikSession": [] - } - ], - "summary": "Torrent shadow summary" - } - } - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt deleted file mode 100644 index 59e0d163..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CachePolicy.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -/** Caller lifecycle events; no background poller or UI is owned by the client. */ -enum class ReadTrigger { IF_STALE, LAUNCH, FOREGROUND, OPEN, POLL, FORCE, CACHE_ONLY } - -/** Refresh lifetimes do not expire last-known-good data or delete it on failure. */ -enum class CachePolicy(internal val lifetimeMillis: Long, internal val capacity: Int? = null) { - LAUNCH(Long.MAX_VALUE), - DAILY(24 * 60 * 60 * 1000L), - FEED(0), - FOREGROUND(Long.MAX_VALUE), - DISCOVERY(30 * 60 * 1000L), - RATINGS(6 * 60 * 60 * 1000L, 300), - ON_OPEN(Long.MAX_VALUE), - SEASON(6 * 60 * 60 * 1000L, 300), - ACTIVITY(30 * 1000L), - NO_CACHE(0), - ; - - internal fun shouldRefresh(trigger: ReadTrigger, entry: CacheEntry?, now: Long): Boolean { - if (trigger == ReadTrigger.CACHE_ONLY) return false - if (entry == null || trigger == ReadTrigger.FORCE) return true - if (this == LAUNCH && trigger == ReadTrigger.LAUNCH) return true - if ((this == FOREGROUND || this == ACTIVITY) && trigger == ReadTrigger.FOREGROUND) return true - if ((this == ON_OPEN || this == SEASON) && trigger == ReadTrigger.OPEN) return true - val expiry = entry.expiresAtMillis - return if (this == FEED) expiry == null || now >= expiry - else lifetimeMillis != Long.MAX_VALUE && now - entry.validatedAtMillis >= lifetimeMillis - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt deleted file mode 100644 index 3bc1c794..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/CacheStore.kt +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import kotlinx.serialization.Serializable - -/** A validated response; saved time is preserved across ETag revalidation. */ -@Serializable -data class CacheEntry( - val body: String, - val etag: String?, - val savedAtMillis: Long, - val validatedAtMillis: Long, - val expiresAtMillis: Long? = null, - val absent: Boolean = false, - val group: String, -) - -/** Storage is partitioned by server and credential digest; implementations never receive tokens. */ -interface CacheStore { - fun read(scope: String, key: String): CacheEntry? - fun write(scope: String, key: String, entry: CacheEntry, capacity: Int? = null) - fun remove(scope: String, key: String) - fun clear(scope: String) -} - -/** Deterministic fake with the same group capacity semantics as the disk store. */ -class MemoryCacheStore : CacheStore { - private val entries = mutableMapOf<Pair<String, String>, CacheEntry>() - - @Synchronized override fun read(scope: String, key: String): CacheEntry? = entries[scope to key] - - @Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) { - entries[scope to key] = entry - if (capacity != null) { - entries.filter { it.key.first == scope && it.value.group == entry.group } - .entries.sortedByDescending { it.value.validatedAtMillis } - .drop(capacity).forEach { entries.remove(it.key) } - } - } - - @Synchronized override fun remove(scope: String, key: String) { entries.remove(scope to key) } - @Synchronized override fun clear(scope: String) { entries.keys.removeAll { it.first == scope } } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt deleted file mode 100644 index 5559f15a..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/ConductarrClient.kt +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.Closeable -import java.io.IOException -import java.time.OffsetDateTime -import java.time.format.DateTimeParseException -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.resumeWithException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import okhttp3.Call -import okhttp3.Callback -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response - -/** Failures contain no response bodies, credentials or transport exception text. */ -sealed interface ClientError { - data object NeedsSignIn : ClientError - data class Http(val status: Int) : ClientError - data object Unreachable : ClientError - data object InvalidResponse : ClientError -} - -/** A successful copy includes the time needed by the Saved HH:MM indicator. */ -data class Saved<T>(val value: T?, val savedAtMillis: Long, val fromDisk: Boolean, val absent: Boolean = false) - -/** Saved arrives before network work; a refresh failure retains it except on 401. */ -sealed interface ReadResult<out T> { - data class Value<T>(val saved: Saved<T>) : ReadResult<T> - data class Failure<T>(val error: ClientError, val saved: Saved<T>? = null) : ReadResult<T> -} - -/** Mutation results deliberately cannot contain a cached receipt. */ -sealed interface MutationResult<out T> { - data class Success<T>(val value: T) : MutationResult<T> - data class Failure(val error: ClientError) : MutationResult<Nothing> -} - -/** Safe diagnostic vocabulary: there is no string sink that could receive a token. */ -enum class ClientEvent { REFRESHED, REVALIDATED, NEEDS_SIGN_IN, HTTP_FAILURE, UNREACHABLE, INVALID_RESPONSE, CACHE_UNAVAILABLE } - -/** Time limits apply to the entire call as well as connection and response I/O. */ -data class ClientTimeouts(val connectSeconds: Long = 10, val readSeconds: Long = 20, val callSeconds: Long = 30) - -/** An immutable identity-scoped client; create another client when the selected person changes. */ -class ConductarrClient( - baseUrl: String, - private val jellyfinToken: String, - private val cache: CacheStore, - timeouts: ClientTimeouts = ClientTimeouts(), - private val nowMillis: () -> Long = System::currentTimeMillis, - private val onEvent: (ClientEvent) -> Unit = {}, -) : Closeable { - private val base = baseUrl.toHttpUrl() - private val scope: String - private val revoked = AtomicBoolean(false) - private val cacheIdentityLock = Any() - private val catalogueReads = Semaphore(2) - private val http = OkHttpClient.Builder() - .connectTimeout(timeouts.connectSeconds, TimeUnit.SECONDS) - .readTimeout(timeouts.readSeconds, TimeUnit.SECONDS) - .callTimeout(timeouts.callSeconds, TimeUnit.SECONDS) - .followRedirects(false) - .followSslRedirects(false) - .retryOnConnectionFailure(false) - .build() - - init { - require(base.username.isEmpty() && base.password.isEmpty() && base.query == null && base.fragment == null) { "Base URL must not contain credentials, query or fragment" } - require(base.isHttps || base.host in setOf("localhost", "127.0.0.1", "::1")) { "Conductarr requires HTTPS" } - require(jellyfinToken.matches(Regex("[0-9a-fA-F]{32}"))) { "Expected a Jellyfin access token" } - scope = digest("$base\n$jellyfinToken") - } - - /** Emit disk LKG first, then revalidate when the endpoint lifetime or caller event requires it. */ - fun <T> read(call: ReadCall<T>, trigger: ReadTrigger = ReadTrigger.IF_STALE): Flow<ReadResult<T>> = flow { - if (revoked.get()) { - emit(ReadResult.Failure(ClientError.NeedsSignIn)) - return@flow - } - val url = url(call.contract, call.segments, call.query) - val group = call.contract.path + if (call.policy == CachePolicy.RATINGS) "-${call.segments["type"]}" else "" - val key = "$group:${digest(url.toString())}" - val entry = if (call.policy != CachePolicy.NO_CACHE) cacheRead(key) else null - val saved = entry?.let { decodeSaved(call.serializer, it, true) } - if (saved != null) emit(ReadResult.Value(saved)) - if (!call.policy.shouldRefresh(trigger, if (saved != null) entry else null, nowMillis())) return@flow - val request = request(url).apply { if (saved != null) entry.etag?.let { header("If-None-Match", it) } }.build() - val result = if (call.contract.path.startsWith("/catalogue/")) { - catalogueReads.withPermit { refresh(call, request, key, group, entry.takeIf { saved != null }, saved) } - } else refresh(call, request, key, group, entry.takeIf { saved != null }, saved) - emit(result) - }.flowOn(Dispatchers.IO) - - /** Execute once; an uncertain request outcome must be checked with the title-requests read. */ - suspend fun <T> mutate(call: MutationCall<T>): MutationResult<T> = withContext(Dispatchers.IO) { - if (revoked.get()) return@withContext MutationResult.Failure(ClientError.NeedsSignIn) - val body = call.body?.toRequestBody("application/json".toMediaType()) - val request = request(url(call.contract, call.segments, emptyMap())).method(call.contract.method, body).build() - try { - http.newCall(request).awaitResponse().use { response -> - if (response.code == 401) return@withContext MutationResult.Failure(revoke()) - if (!response.isSuccessful) return@withContext MutationResult.Failure(ClientError.Http(response.code)) - val value = WireJson.decodeFromString(call.serializer, response.body.string()) - if (revoked.get()) MutationResult.Failure(ClientError.NeedsSignIn) - else MutationResult.Success(value) - } - } catch (_: IOException) { - onEvent(ClientEvent.UNREACHABLE) - MutationResult.Failure(ClientError.Unreachable) - } catch (_: SerializationException) { - onEvent(ClientEvent.INVALID_RESPONSE) - MutationResult.Failure(ClientError.InvalidResponse) - } - } - - private suspend fun <T> refresh(call: ReadCall<T>, request: Request, key: String, group: String, entry: CacheEntry?, saved: Saved<T>?): ReadResult<T> { - return try { - http.newCall(request).awaitResponse().use { response -> - if (response.code == 401) return ReadResult.Failure(revoke()) - if (revoked.get()) return ReadResult.Failure(ClientError.NeedsSignIn) - if (response.code == 304) { - if (entry == null || saved == null) return ReadResult.Failure(ClientError.InvalidResponse) - cacheWrite(key, entry.copy(etag = response.header("ETag") ?: entry.etag, validatedAtMillis = nowMillis()), call.policy) - onEvent(ClientEvent.REVALIDATED) - return ReadResult.Value(saved.copy(fromDisk = false)) - } - if (response.code == 404 && call.policy == CachePolicy.RATINGS) { - val absent = CacheEntry("", null, nowMillis(), nowMillis(), absent = true, group = group) - cacheWrite(key, absent, call.policy) - return ReadResult.Value(Saved(null, absent.savedAtMillis, false, true)) - } - if (!response.isSuccessful) { - onEvent(ClientEvent.HTTP_FAILURE) - return ReadResult.Failure(ClientError.Http(response.code), saved) - } - val body = response.body.string() - val value = WireJson.decodeFromString(call.serializer, body) - val timestamp = nowMillis() - val updated = CacheEntry(body, response.header("ETag"), timestamp, timestamp, feedExpiry(call.policy, body), group = group) - if (call.policy != CachePolicy.NO_CACHE) cacheWrite(key, updated, call.policy) - onEvent(ClientEvent.REFRESHED) - ReadResult.Value(Saved(value, timestamp, false)) - } - } catch (_: IOException) { - onEvent(ClientEvent.UNREACHABLE) - ReadResult.Failure(ClientError.Unreachable, saved) - } catch (_: SerializationException) { - onEvent(ClientEvent.INVALID_RESPONSE) - ReadResult.Failure(ClientError.InvalidResponse, saved) - } - } - - private fun request(url: HttpUrl): Request.Builder = Request.Builder().url(url) - .header("Authorization", "MediaBrowser Token=\"$jellyfinToken\"") - .header("Accept", "application/json") - - private fun url(contract: RouteContract, segments: Map<String, String>, query: Map<String, String>): HttpUrl { - val builder = base.newBuilder() - for (segment in contract.path.trimStart('/').split('/')) { - builder.addPathSegment(if (segment.startsWith('{')) segments.getValue(segment.removeSurrounding("{", "}")) else segment) - } - query.toSortedMap().forEach { (name, value) -> builder.addQueryParameter(name, value) } - return builder.build() - } - - private fun <T> decodeSaved(serializer: KSerializer<T>, entry: CacheEntry, fromDisk: Boolean): Saved<T>? = try { - Saved(if (entry.absent) null else WireJson.decodeFromString(serializer, entry.body), entry.savedAtMillis, fromDisk, entry.absent) - } catch (_: SerializationException) { null } - - private fun cacheRead(key: String): CacheEntry? = try { cache.read(scope, key) } catch (_: IOException) { - onEvent(ClientEvent.CACHE_UNAVAILABLE) - null - } - - private fun cacheWrite(key: String, entry: CacheEntry, policy: CachePolicy) = synchronized(cacheIdentityLock) { - if (!revoked.get()) { - try { cache.write(scope, key, entry, policy.capacity) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) } - } - } - - private fun revoke(): ClientError { - synchronized(cacheIdentityLock) { - revoked.set(true) - try { cache.clear(scope) } catch (_: IOException) { onEvent(ClientEvent.CACHE_UNAVAILABLE) } - } - onEvent(ClientEvent.NEEDS_SIGN_IN) - return ClientError.NeedsSignIn - } - - private fun feedExpiry(policy: CachePolicy, body: String): Long? { - if (policy != CachePolicy.FEED) return null - val expiry = WireJson.parseToJsonElement(body).jsonObject["expires_at"]?.jsonPrimitive?.content ?: return null - return try { OffsetDateTime.parse(expiry).toInstant().toEpochMilli() } catch (_: DateTimeParseException) { null } - } - - /** Release this instance's network resources; disk LKG survives. */ - override fun close() { - http.dispatcher.cancelAll() - http.dispatcher.executorService.shutdown() - http.connectionPool.evictAll() - } -} - -private suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cancel() } - enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - if (!continuation.isCancelled) continuation.resumeWithException(e) - } - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) { _, value, _ -> value.close() } - } - }) -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt deleted file mode 100644 index fe34a9b2..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/FileCacheStore.kt +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.io.IOException -import java.security.MessageDigest -import kotlinx.serialization.SerializationException - -/** App-private disk cache, safe to reopen after process death; one store instance per directory. */ -class FileCacheStore(private val directory: File) : CacheStore { - @Synchronized override fun read(scope: String, key: String): CacheEntry? { - val file = entryFile(scope, key) - if (!file.exists()) return null - return try { - WireJson.decodeFromString<CacheEntry>(file.readText()) - } catch (_: SerializationException) { - file.delete() - null - } - } - - @Synchronized override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) { - val target = entryFile(scope, key) - val parent = target.parentFile!! - if (!parent.isDirectory && !parent.mkdirs()) throw IOException("Cache directory unavailable") - val temporary = File.createTempFile("entry-", ".tmp", parent) - try { - temporary.outputStream().use { output -> - output.write(WireJson.encodeToString(CacheEntry.serializer(), entry).toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - if (!temporary.renameTo(target)) throw IOException("Cache replacement failed") - target.setLastModified(entry.validatedAtMillis) - } finally { - temporary.delete() - } - if (capacity != null) { - parent.listFiles { file -> file.extension == "json" } - ?.sortedByDescending { it.lastModified() }?.drop(capacity)?.forEach { it.delete() } - } - } - - @Synchronized override fun remove(scope: String, key: String) { entryFile(scope, key).delete() } - @Synchronized override fun clear(scope: String) { File(directory, digest(scope)).deleteRecursively() } - - private fun entryFile(scope: String, key: String): File = - File(File(File(directory, digest(scope)), digest(key.substringBefore(':'))), digest(key) + ".json") -} - -/** Opaque storage names never expose credentials, query parameters or household names. */ -internal fun digest(value: String): String { - val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) - val hex = "0123456789abcdef" - val characters = CharArray(bytes.size * 2) - bytes.forEachIndexed { index, byte -> - val number = byte.toInt() and 0xff - characters[index * 2] = hex[number ushr 4] - characters[index * 2 + 1] = hex[number and 0x0f] - } - return String(characters) -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt deleted file mode 100644 index e2f3bd03..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/TvRoutes.kt +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import kotlinx.serialization.KSerializer -import org.justinvisser.wolfskin.conductarr.model.* - -/** A TV-used operation and its pinned request/response contract. */ -class RouteContract internal constructor( - val method: String, - val path: String, - val responseSchema: String, - val requestSchema: String? = null, -) - -/** A typed read; only TvRoutes can construct paths and policies. */ -class ReadCall<T> internal constructor( - internal val contract: RouteContract, - internal val serializer: KSerializer<T>, - internal val segments: Map<String, String> = emptyMap(), - internal val query: Map<String, String> = emptyMap(), - internal val policy: CachePolicy, -) - -/** A single mutation; failures are returned without automatic retry or replay. */ -class MutationCall<T> internal constructor( - internal val contract: RouteContract, - internal val serializer: KSerializer<T>, - internal val segments: Map<String, String>, - internal val body: String? = null, -) - -/** The catalogue media type accepted by TV operations. */ -enum class MediaType(internal val wire: String) { MOVIE("movie"), TV("tv") } - -/** Feed filter; the unfiltered feed uses all. */ -enum class MediaFilter(internal val wire: String) { ALL("all"), MOVIE("movie"), TV("tv") } - -/** The four discovery sorts used by the TV. */ -enum class DiscoverSort(internal val wire: String) { POPULAR("popular"), NEW("new"), TOP("top"), UPCOMING("upcoming") } - -/** The complete TV route allowlist; caller identity is never a request body field. */ -object TvRoutes { - private val operator = RouteContract("GET", "/api/v1/operator", "Operator") - private val profiles = RouteContract("GET", "/api/v1/recommendations/profiles", "RecommendationProfiles") - private val feed = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/feed", "RecommendationFeed") - private val dismissals = RouteContract("GET", "/api/v1/recommendations/profiles/{profile}/dismissals", "RecommendationDismissals") - private val hide = RouteContract("POST", dismissals.path, "RecommendationHideResult", "RecommendationHideInputBody") - private val restore = RouteContract("DELETE", "${dismissals.path}/{type}/{tmdb}", "RecommendationRestoreResult") - private val options = RouteContract("GET", "/catalogue/options", "Options") - private val discover = RouteContract("GET", "/catalogue/discover", "Page") - private val title = RouteContract("GET", "/catalogue/titles/{tmdb}/{type}", "Detail") - private val cast = RouteContract("GET", "${title.path}/cast", "TitleCast") - private val related = RouteContract("GET", "${title.path}/related", "Page") - private val enrichment = RouteContract("GET", "${title.path}/enrichment", "Enrichment") - private val ratings = RouteContract("GET", "${title.path}/ratings", "TitleRatings") - private val display = RouteContract("GET", "${title.path}/display", "Display") - private val season = RouteContract("GET", "/catalogue/tv/{tmdb}/seasons/{season}", "SeasonDetail") - private val performer = RouteContract("GET", "/catalogue/performers/{performer}", "PerformerDetail") - private val filmography = RouteContract("GET", "${performer.path}/filmography", "FilmographyPage") - private val activity = RouteContract("GET", "/api/v1/activity", "ActivityFeed") - private val readiness = RouteContract("GET", "/api/v1/activity/titles/{tmdb}/{type}", "ViewerReadiness") - private val requests = RouteContract("GET", "/api/v1/requests/titles/{tmdb}/{type}", "TitleRequestsBody") - private val request = RouteContract("POST", "/api/v1/requests", "RequestReceipt", "RequestIntentBody") - private val audiences = RouteContract("GET", "/curation/library-audiences", "LibraryAudiencesBody") - - val contracts: List<RouteContract> = listOf( - operator, profiles, feed, dismissals, hide, restore, options, discover, title, cast, - related, enrichment, ratings, display, season, performer, filmography, activity, readiness, requests, request, audiences, - ) - - fun operator() = ReadCall(operator, Operator.serializer(), policy = CachePolicy.LAUNCH) - fun profiles() = ReadCall(profiles, RecommendationProfiles.serializer(), policy = CachePolicy.LAUNCH) - fun audiences() = ReadCall(audiences, LibraryAudiencesBody.serializer(), policy = CachePolicy.DAILY) - - fun feed(profile: String, media: MediaFilter = MediaFilter.ALL) = ReadCall( - feed, RecommendationFeed.serializer(), mapOf("profile" to profile), - mapOf("media_type" to media.wire), CachePolicy.FEED, - ) - - fun dismissals(profile: String) = ReadCall( - dismissals, RecommendationDismissals.serializer(), mapOf("profile" to profile), policy = CachePolicy.ON_OPEN, - ) - - fun options(media: MediaType) = ReadCall( - options, Options.serializer(), query = mapOf("media_type" to media.wire), policy = CachePolicy.FOREGROUND, - ) - - fun discover(media: MediaType, page: Int = 1, genreId: Long? = null, serviceId: Long? = null, sort: DiscoverSort = DiscoverSort.POPULAR): ReadCall<Page> { - require(page in 1..500) - val query = buildMap { - put("media_type", media.wire) - put("page", page.toString()) - put("sort", sort.wire) - genreId?.let { put("genre_id", it.toString()) } - serviceId?.let { put("service_id", it.toString()) } - } - return ReadCall(discover, Page.serializer(), query = query, policy = CachePolicy.DISCOVERY) - } - - fun title(tmdb: Long, media: MediaType) = ReadCall(title, Detail.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun cast(tmdb: Long, media: MediaType) = ReadCall(cast, TitleCast.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun enrichment(tmdb: Long, media: MediaType) = ReadCall(enrichment, Enrichment.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun ratings(tmdb: Long, media: MediaType) = ReadCall(ratings, TitleRatings.serializer(), titleSegments(tmdb, media), policy = CachePolicy.RATINGS) - fun display(tmdb: Long, media: MediaType) = ReadCall(display, Display.serializer(), titleSegments(tmdb, media), policy = CachePolicy.ON_OPEN) - fun related(tmdb: Long, media: MediaType, page: Int = 1) = ReadCall( - related, Page.serializer(), titleSegments(tmdb, media), mapOf("page" to page.toString()), CachePolicy.ON_OPEN, - ) - fun season(tmdb: Long, season: Int): ReadCall<SeasonDetail> { - require(tmdb > 0 && season >= 0) - return ReadCall(this.season, SeasonDetail.serializer(), mapOf("tmdb" to tmdb.toString(), "season" to season.toString()), policy = CachePolicy.SEASON) - } - fun performer(performer: Long) = ReadCall( - this.performer, PerformerDetail.serializer(), mapOf("performer" to performer.toString()), policy = CachePolicy.ON_OPEN, - ) - fun filmography(performer: Long, page: Int = 1, pageSize: Int = 20) = ReadCall( - filmography, FilmographyPage.serializer(), mapOf("performer" to performer.toString()), - mapOf("page" to page.toString(), "page_size" to pageSize.toString()), CachePolicy.ON_OPEN, - ) - fun activity() = ReadCall(activity, ActivityFeed.serializer(), query = mapOf("requester" to "all"), policy = CachePolicy.ACTIVITY) - fun readiness(tmdb: Long, media: MediaType, viewer: String) = ReadCall( - readiness, ViewerReadiness.serializer(), titleSegments(tmdb, media), mapOf("viewer" to viewer), CachePolicy.ON_OPEN, - ) - fun requests(tmdb: Long, media: MediaType) = ReadCall( - requests, TitleRequestsBody.serializer(), titleSegments(tmdb, media), policy = CachePolicy.NO_CACHE, - ) - fun request(body: RequestIntentBody) = MutationCall( - request, RequestReceipt.serializer(), emptyMap(), WireJson.encodeToString(RequestIntentBody.serializer(), body), - ) - fun hide(profile: String, body: RecommendationHideInputBody) = MutationCall( - hide, RecommendationHideResult.serializer(), mapOf("profile" to profile), - WireJson.encodeToString(RecommendationHideInputBody.serializer(), body), - ) - fun restore(profile: String, tmdb: Long, media: MediaType) = MutationCall( - restore, RecommendationRestoreResult.serializer(), titleSegments(tmdb, media) + ("profile" to profile), - ) - - private fun titleSegments(tmdb: Long, media: MediaType): Map<String, String> { - require(tmdb > 0) - return mapOf("tmdb" to tmdb.toString(), "type" to media.wire) - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt deleted file mode 100644 index 1e80eb25..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/WireJson.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import kotlinx.serialization.json.Json - -/** Shared wire encoding omits absent optional request fields. */ -internal val WireJson = Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = false -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt deleted file mode 100644 index fe181459..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActingCredit.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ActingCredit wire contract from the pinned Conductarr schema. */ -@Serializable -data class ActingCredit( - @SerialName("character") val character: String, - @SerialName("credit_id") val creditId: String, - @SerialName("episode_count") val episodeCount: Long? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt deleted file mode 100644 index 8f36f041..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ActivityFeed.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ActivityFeed wire contract from the pinned Conductarr schema. */ -@Serializable -data class ActivityFeed( - @SerialName("items") val items: List<RequestProgress>?, - @SerialName("requester") val requester: String, - @SerialName("sources") val sources: List<EvidenceSource>?, - @SerialName("truncated") val truncated: Boolean, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt deleted file mode 100644 index 36ff72fd..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Actor.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Actor wire contract from the pinned Conductarr schema. */ -@Serializable -data class Actor( - @SerialName("name") val name: String, - @SerialName("requester") val requester: String, - @SerialName("role") val role: Role, -) { - /** Wire values for role. */ - @Serializable - enum class Role { - @SerialName("admin") ADMIN, - @SerialName("household") HOUSEHOLD, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt deleted file mode 100644 index 81ef5607..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Candidate.kt +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Candidate wire contract from the pinned Conductarr schema. */ -@Serializable -data class Candidate( - @SerialName("backdrop_path") val backdropPath: String? = null, - @SerialName("contributions") val contributions: List<SeedContribution>?, - @SerialName("genre_ids") val genreIds: List<Long>? = null, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("overview") val overview: String, - @SerialName("poster_path") val posterPath: String, - @SerialName("score") val score: Double, - @SerialName("title") val title: String, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("tvdb_id") val tvdbId: Long, - @SerialName("year") val year: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt deleted file mode 100644 index 9dca14e1..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/CastMember.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** CastMember wire contract from the pinned Conductarr schema. */ -@Serializable -data class CastMember( - @SerialName("character") val character: String, - @SerialName("credit_id") val creditId: String, - @SerialName("name") val name: String, - @SerialName("order") val order: Long, - @SerialName("performer_id") val performerId: Long, - @SerialName("profile_url") val profileUrl: String? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt deleted file mode 100644 index 85cd9a88..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Detail.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Detail wire contract from the pinned Conductarr schema. */ -@Serializable -data class Detail( - @SerialName("genres") val genres: List<String>?, - @SerialName("held_title") val heldTitle: HeldTitle? = null, - @SerialName("metadata") val metadata: TitleMetadata, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("ratings") val ratings: List<Rating>? = null, - @SerialName("runtime") val runtime: Long? = null, - @SerialName("seasons") val seasons: List<Season>?, - @SerialName("state") val state: State, - @SerialName("title") val title: Title, -) { - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("partial") PARTIAL, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt deleted file mode 100644 index a198abd1..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Discovery.kt +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Discovery wire contract from the pinned Conductarr schema. */ -@Serializable -data class Discovery( - @SerialName("genre_id") val genreId: Long, - @SerialName("next") val next: String? = null, - @SerialName("region") val region: String, - @SerialName("service_id") val serviceId: Long, - @SerialName("sort") val sort: Sort, - @SerialName("state") val state: State, -) { - /** Wire values for sort. */ - @Serializable - enum class Sort { - @SerialName("trending") TRENDING, - @SerialName("popular") POPULAR, - @SerialName("new") NEW, - @SerialName("top") TOP, - @SerialName("upcoming") UPCOMING, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("empty") EMPTY, - @SerialName("partial") PARTIAL, - @SerialName("exhausted") EXHAUSTED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt deleted file mode 100644 index 2837856e..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Display.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Display wire contract from the pinned Conductarr schema. */ -@Serializable -data class Display( - @SerialName("media_type") val mediaType: MediaType, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("poster_url") val posterUrl: String? = null, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, -) { - @Serializable - enum class MediaType { @SerialName("movie") MOVIE, @SerialName("tv") TV } - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt deleted file mode 100644 index 4329d346..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Enrichment.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Enrichment wire contract from the pinned Conductarr schema. */ -@Serializable -data class Enrichment( - @SerialName("genres") val genres: List<String>?, - @SerialName("metadata") val metadata: TitleMetadata, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("ratings") val ratings: List<Rating>?, - @SerialName("runtime") val runtime: Long? = null, - @SerialName("seasons") val seasons: List<Season>?, - @SerialName("state") val state: State, - @SerialName("title") val title: Title, -) { - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt deleted file mode 100644 index 494d0094..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Episode.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Episode wire contract from the pinned Conductarr schema. */ -@Serializable -data class Episode( - @SerialName("air_date") val airDate: String? = null, - @SerialName("name") val name: String, - @SerialName("number") val number: Long, - @SerialName("overview") val overview: String, - @SerialName("recorded") val recorded: Recorded, - @SerialName("vote_average") val voteAverage: Double? = null, - @SerialName("vote_count") val voteCount: Long? = null, -) { - /** Wire values for recorded. */ - @Serializable - enum class Recorded { - @SerialName("recorded") RECORDED, - @SerialName("missing") MISSING, - @SerialName("unknown") UNKNOWN, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt deleted file mode 100644 index b0c2a091..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeFileChip.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** EpisodeFileChip wire contract from the pinned Conductarr schema. */ -@Serializable -data class EpisodeFileChip( - @SerialName("quality") val quality: String? = null, - @SerialName("release_name") val releaseName: String? = null, - @SerialName("resolution") val resolution: String? = null, - @SerialName("size_bytes") val sizeBytes: Long, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt deleted file mode 100644 index 1a56793b..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeRow.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** EpisodeRow wire contract from the pinned Conductarr schema. */ -@Serializable -data class EpisodeRow( - @SerialName("air_date") val airDate: String? = null, - @SerialName("episode") val episode: Long, - @SerialName("file") val file: EpisodeFileChip? = null, - @SerialName("has_file") val hasFile: Boolean, - @SerialName("key") val key: String, - @SerialName("monitored") val monitored: Boolean, - @SerialName("queue_state") val queueState: String? = null, - @SerialName("runtime_minutes") val runtimeMinutes: Long? = null, - @SerialName("season") val season: Long, - @SerialName("title") val title: String? = null, - @SerialName("watched") val watched: Map<String,Boolean>, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt deleted file mode 100644 index aab5e114..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EpisodeView.kt +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** EpisodeView wire contract from the pinned Conductarr schema. */ -@Serializable -data class EpisodeView( - @SerialName("owners") val owners: List<String>?, - @SerialName("seasons") val seasons: List<SeasonGroup>?, - @SerialName("title") val title: String, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("users") val users: List<String>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt deleted file mode 100644 index 3aef3a95..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorDetail.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -/** ErrorDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class ErrorDetail( - @SerialName("location") val location: String? = null, - @SerialName("message") val message: String? = null, - @SerialName("value") val `value`: JsonElement? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt deleted file mode 100644 index 1c926492..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ErrorModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ErrorModel wire contract from the pinned Conductarr schema. */ -@Serializable -data class ErrorModel( - @SerialName("detail") val detail: String? = null, - @SerialName("errors") val errors: List<ErrorDetail>? = null, - @SerialName("instance") val instance: String? = null, - @SerialName("status") val status: Long? = null, - @SerialName("title") val title: String? = null, - @SerialName("type") val type: String? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt deleted file mode 100644 index 3d3fd6e5..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/EvidenceSource.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** EvidenceSource wire contract from the pinned Conductarr schema. */ -@Serializable -data class EvidenceSource( - @SerialName("detail") val detail: String? = null, - @SerialName("name") val name: String, - @SerialName("state") val state: State, -) { - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt deleted file mode 100644 index 5fc58321..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FileDetail.kt +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** FileDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class FileDetail( - @SerialName("audio") val audio: String? = null, - @SerialName("container") val container: String? = null, - @SerialName("quality") val quality: String? = null, - @SerialName("release_name") val releaseName: String? = null, - @SerialName("resolution") val resolution: String? = null, - @SerialName("size_bytes") val sizeBytes: Long, - @SerialName("video") val video: String? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt deleted file mode 100644 index 22328337..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyPage.kt +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** FilmographyPage wire contract from the pinned Conductarr schema. */ -@Serializable -data class FilmographyPage( - @SerialName("completeness") val completeness: Completeness, - @SerialName("items") val items: List<FilmographyTitle>?, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("page") val page: Long, - @SerialName("page_size") val pageSize: Long, - @SerialName("performer_id") val performerId: Long, - @SerialName("source") val source: Source, - @SerialName("source_paging") val sourcePaging: SourcePaging, - @SerialName("total_pages") val totalPages: Long? = null, - @SerialName("total_results") val totalResults: Long? = null, -) { - /** Wire values for completeness. */ - @Serializable - enum class Completeness { - @SerialName("returned-non-adult-cast") RETURNED_NON_ADULT_CAST, - } - - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("seerr") SEERR, - } - - /** Wire values for source_paging. */ - @Serializable - enum class SourcePaging { - @SerialName("unpaged") UNPAGED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt deleted file mode 100644 index 69115f23..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/FilmographyTitle.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** FilmographyTitle wire contract from the pinned Conductarr schema. */ -@Serializable -data class FilmographyTitle( - @SerialName("credits") val credits: List<ActingCredit>?, - @SerialName("title") val title: Title, - @SerialName("users") val users: List<String>?, - @SerialName("visibility_state") val visibilityState: VisibilityState, -) { - /** Wire values for visibility_state. */ - @Serializable - enum class VisibilityState { - @SerialName("configured") CONFIGURED, - @SerialName("unknown") UNKNOWN, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt deleted file mode 100644 index fff1dbad..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Genre.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Genre wire contract from the pinned Conductarr schema. */ -@Serializable -data class Genre( - @SerialName("id") val id: Long, - @SerialName("name") val name: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt deleted file mode 100644 index 76247a3a..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HealthDetail.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** HealthDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class HealthDetail( - @SerialName("failed") val failed: Boolean, - @SerialName("has_file") val hasFile: Boolean, - @SerialName("in_queue") val inQueue: Boolean, - @SerialName("missing") val missing: Boolean, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt deleted file mode 100644 index e3e6fbba..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/HeldTitle.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** HeldTitle wire contract from the pinned Conductarr schema. */ -@Serializable -data class HeldTitle( - @SerialName("detail") val detail: ItemDetail, - @SerialName("episodes") val episodes: EpisodeView? = null, - @SerialName("libraries") val libraries: List<String>?, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("state") val state: State, - @SerialName("versions") val versions: List<TitleVersion>?, - @SerialName("watched") val watched: Map<String,Boolean>, -) { - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("partial") PARTIAL, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt deleted file mode 100644 index 17b7357b..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ItemDetail.kt +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ItemDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class ItemDetail( - @SerialName("backdrop_ref") val backdropRef: String? = null, - @SerialName("file") val file: FileDetail? = null, - @SerialName("health") val health: HealthDetail, - @SerialName("is_anime") val isAnime: Boolean, - @SerialName("media_type") val mediaType: String, - @SerialName("owners") val owners: List<String>?, - @SerialName("poster_ref") val posterRef: String? = null, - @SerialName("runtime") val runtime: Long? = null, - @SerialName("source") val source: SourceDetail, - @SerialName("title") val title: String, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("users") val users: List<String>?, - @SerialName("year") val year: Long? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt deleted file mode 100644 index 11c69a33..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudience.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** LibraryAudience wire contract from the pinned Conductarr schema. */ -@Serializable -data class LibraryAudience( - @SerialName("is_anime") val isAnime: Boolean, - @SerialName("libraries") val libraries: List<String>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("owner") val owner: String, - @SerialName("users") val users: List<String>?, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt deleted file mode 100644 index b3ed0ebf..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryAudiencesBody.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** LibraryAudiencesBody wire contract from the pinned Conductarr schema. */ -@Serializable -data class LibraryAudiencesBody( - @SerialName("audiences") val audiences: List<LibraryAudience>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt deleted file mode 100644 index e439b750..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/LibraryObservation.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** LibraryObservation wire contract from the pinned Conductarr schema. */ -@Serializable -data class LibraryObservation( - @SerialName("detail") val detail: String? = null, - @SerialName("observed_at") val observedAt: String? = null, - @SerialName("source") val source: String, - @SerialName("state") val state: State, -) { - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("partial") PARTIAL, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt deleted file mode 100644 index 75d1e996..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OpenLink.kt +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** OpenLink wire contract from the pinned Conductarr schema. */ -@Serializable -data class OpenLink( - @SerialName("detail") val detail: String? = null, - @SerialName("state") val state: State, - @SerialName("url") val url: String? = null, -) { - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - @SerialName("unconfigured") UNCONFIGURED, - @SerialName("not_permitted") NOT_PERMITTED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt deleted file mode 100644 index edb9f0cf..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Operator.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Operator wire contract from the pinned Conductarr schema. */ -@Serializable -data class Operator( - @SerialName("capabilities") val capabilities: OperatorCapabilities, - @SerialName("name") val name: String, - @SerialName("recommendation_profile") val recommendationProfile: String, - @SerialName("requester") val requester: String, - @SerialName("role") val role: Role, - @SerialName("subject") val subject: String, - @SerialName("watch_user") val watchUser: String, -) { - /** Wire values for role. */ - @Serializable - enum class Role { - @SerialName("admin") ADMIN, - @SerialName("household") HOUSEHOLD, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt deleted file mode 100644 index 777f1c03..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/OperatorCapabilities.kt +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** OperatorCapabilities wire contract from the pinned Conductarr schema. */ -@Serializable -data class OperatorCapabilities( - @SerialName("administer") val administer: Boolean, - @SerialName("manage_library") val manageLibrary: Boolean, - @SerialName("manage_memberships") val manageMemberships: Boolean, - @SerialName("recommendation_profiles") val recommendationProfiles: List<String>?, - @SerialName("request_media") val requestMedia: Boolean, - @SerialName("requesters") val requesters: List<String>?, - @SerialName("watch_users") val watchUsers: List<String>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt deleted file mode 100644 index ebd7b132..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Option.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Option wire contract from the pinned Conductarr schema. */ -@Serializable -data class Option( - @SerialName("id") val id: Long, - @SerialName("logo_url") val logoUrl: String? = null, - @SerialName("name") val name: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt deleted file mode 100644 index b0a9839b..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Options.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Options wire contract from the pinned Conductarr schema. */ -@Serializable -data class Options( - @SerialName("genres") val genres: List<Option>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("region") val region: String, - @SerialName("services") val services: List<Option>?, - @SerialName("state") val state: State, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("partial") PARTIAL, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt deleted file mode 100644 index 816ea50f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Page.kt +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Page wire contract from the pinned Conductarr schema. */ -@Serializable -data class Page( - @SerialName("discovery") val discovery: Discovery? = null, - @SerialName("items") val items: List<Title>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("page") val page: Long, - @SerialName("query") val query: String, - @SerialName("source") val source: Source, - @SerialName("state") val state: State, - @SerialName("total_pages") val totalPages: Long, - @SerialName("total_results") val totalResults: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("all") ALL, - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("search") SEARCH, - @SerialName("general") GENERAL, - @SerialName("related") RELATED, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("partial") PARTIAL, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt deleted file mode 100644 index e5773b1a..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/PerformerDetail.kt +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** PerformerDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class PerformerDetail( - @SerialName("also_known_as") val alsoKnownAs: List<String>? = null, - @SerialName("biography") val biography: String? = null, - @SerialName("birthday") val birthday: String? = null, - @SerialName("deathday") val deathday: String? = null, - @SerialName("known_for_department") val knownForDepartment: String? = null, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("name") val name: String? = null, - @SerialName("performer_id") val performerId: Long, - @SerialName("place_of_birth") val placeOfBirth: String? = null, - @SerialName("profile_url") val profileUrl: String? = null, - @SerialName("source") val source: Source, -) { - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("seerr") SEERR, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt deleted file mode 100644 index f65abc82..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Person.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Person wire contract from the pinned Conductarr schema. */ -@Serializable -data class Person( - @SerialName("key") val key: String, - @SerialName("name") val name: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt deleted file mode 100644 index 6c19c15a..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Provider.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Provider wire contract from the pinned Conductarr schema. */ -@Serializable -data class Provider( - @SerialName("id") val id: Long, - @SerialName("logo_url") val logoUrl: String? = null, - @SerialName("name") val name: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt deleted file mode 100644 index 741b856d..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Rating.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Rating wire contract from the pinned Conductarr schema. */ -@Serializable -data class Rating( - @SerialName("score") val score: Double? = null, - @SerialName("source") val source: Source, - @SerialName("state") val state: State, - @SerialName("status") val status: String? = null, - @SerialName("url") val url: String? = null, - @SerialName("votes") val votes: Long? = null, -) { - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("rt_critics") RT_CRITICS, - @SerialName("rt_audience") RT_AUDIENCE, - @SerialName("imdb") IMDB, - @SerialName("tmdb") TMDB, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("unrated") UNRATED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt deleted file mode 100644 index cb39424f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadProfile.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ReadProfile wire contract from the pinned Conductarr schema. */ -@Serializable -data class ReadProfile( - @SerialName("key") val key: String, - @SerialName("name") val name: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt deleted file mode 100644 index 1192a152..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ReadinessStage.kt +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ReadinessStage wire contract from the pinned Conductarr schema. */ -@Serializable -data class ReadinessStage( - @SerialName("name") val name: Name, - @SerialName("observed_at") val observedAt: String? = null, - @SerialName("source") val source: String, - @SerialName("state") val state: State, - @SerialName("summary") val summary: String, -) { - /** Wire values for name. */ - @Serializable - enum class Name { - @SerialName("request") REQUEST, - @SerialName("acquisition") ACQUISITION, - @SerialName("visibility") VISIBILITY, - @SerialName("playback") PLAYBACK, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("done") DONE, - @SerialName("partial") PARTIAL, - @SerialName("in_progress") IN_PROGRESS, - @SerialName("none") NONE, - @SerialName("unknown") UNKNOWN, - @SerialName("stale") STALE, - @SerialName("conflicting") CONFLICTING, - @SerialName("failed") FAILED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt deleted file mode 100644 index 583b5933..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationCoverage.kt +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationCoverage wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationCoverage( - @SerialName("complete") val complete: Boolean, - @SerialName("excluded") val excluded: Long, - @SerialName("matched") val matched: Long, - @SerialName("total") val total: Long, - @SerialName("unresolved") val unresolved: Long, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt deleted file mode 100644 index 2364e335..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissal.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationDismissal wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationDismissal( - @SerialName("hidden_at") val hiddenAt: String, - @SerialName("hidden_by") val hiddenBy: String, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("poster_path") val posterPath: String, - @SerialName("title") val title: String, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("year") val year: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt deleted file mode 100644 index 0ac1c48f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationDismissals.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationDismissals wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationDismissals( - @SerialName("dismissals") val dismissals: List<RecommendationDismissal>?, - @SerialName("profile") val profile: String, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt deleted file mode 100644 index d4114c9f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFact.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationFact wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationFact( - @SerialName("genre_source") val genreSource: GenreSource, - @SerialName("match") val match: Match, - @SerialName("metadata") val metadata: TitleMetadata, -) { - /** Wire values for genre_source. */ - @Serializable - enum class GenreSource { - @SerialName("ranked") RANKED, - @SerialName("metadata") METADATA, - @SerialName("unknown") UNKNOWN, - } - - /** Wire values for match. */ - @Serializable - enum class Match { - @SerialName("matched") MATCHED, - @SerialName("excluded") EXCLUDED, - @SerialName("unresolved") UNRESOLVED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt deleted file mode 100644 index 4f75e359..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationFeed.kt +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationFeed wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationFeed( - @SerialName("attempted_at") val attemptedAt: String? = null, - @SerialName("expires_at") val expiresAt: String? = null, - @SerialName("failure") val failure: Failure? = null, - @SerialName("materialization") val materialization: Materialization, - @SerialName("movies") val movies: List<Candidate>?, - @SerialName("observed_at") val observedAt: String? = null, - @SerialName("outcome") val outcome: Outcome, - @SerialName("profile") val profile: ReadProfile, - @SerialName("projection") val projection: RecommendationProjection, - @SerialName("shows") val shows: List<Candidate>?, - @SerialName("state") val state: State, -) { - /** Wire values for failure. */ - @Serializable - enum class Failure { - @SerialName("profile_resolution") PROFILE_RESOLUTION, - @SerialName("memberships") MEMBERSHIPS, - @SerialName("history") HISTORY, - @SerialName("candidates") CANDIDATES, - @SerialName("cycle") CYCLE, - } - - /** Wire values for materialization. */ - @Serializable - enum class Materialization { - @SerialName("unknown") UNKNOWN, - @SerialName("pending") PENDING, - @SerialName("current") CURRENT, - @SerialName("failed") FAILED, - } - - /** Wire values for outcome. */ - @Serializable - enum class Outcome { - @SerialName("unknown") UNKNOWN, - @SerialName("ranked") RANKED, - @SerialName("no_seeds") NO_SEEDS, - @SerialName("empty") EMPTY, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("current") CURRENT, - @SerialName("stale") STALE, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt deleted file mode 100644 index 8d25fa8f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideInputBody.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationHideInputBody wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationHideInputBody( - @SerialName("media_type") val mediaType: MediaType, - @SerialName("poster_path") val posterPath: String? = null, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("year") val year: Long? = null, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt deleted file mode 100644 index eb9bb3f1..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationHideResult.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationHideResult wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationHideResult( - @SerialName("created") val created: Boolean, - @SerialName("dismissal") val dismissal: RecommendationDismissal, - @SerialName("materialization") val materialization: Materialization, - @SerialName("profile") val profile: String, -) { - /** Wire values for materialization. */ - @Serializable - enum class Materialization { - @SerialName("current") CURRENT, - @SerialName("failed") FAILED, - @SerialName("unchanged") UNCHANGED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt deleted file mode 100644 index d6245a99..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProfiles.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationProfiles wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationProfiles( - @SerialName("default_profile") val defaultProfile: String, - @SerialName("profiles") val profiles: List<ReadProfile>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt deleted file mode 100644 index a161ae72..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationProjection.kt +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationProjection wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationProjection( - @SerialName("coverage") val coverage: RecommendationCoverage, - @SerialName("facts") val facts: List<RecommendationFact>?, - @SerialName("genre_id") val genreId: Long, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("service_id") val serviceId: Long, - @SerialName("unresolved") val unresolved: List<Candidate>?, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("all") ALL, - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt deleted file mode 100644 index 32a1d401..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RecommendationRestoreResult.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RecommendationRestoreResult wire contract from the pinned Conductarr schema. */ -@Serializable -data class RecommendationRestoreResult( - @SerialName("media_type") val mediaType: MediaType, - @SerialName("profile") val profile: String, - @SerialName("restored") val restored: Boolean, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt deleted file mode 100644 index c225a816..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestIntentBody.kt +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestIntentBody wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestIntentBody( - @SerialName("media_type") val mediaType: MediaType, - @SerialName("recommendation_profile") val recommendationProfile: String? = null, - @SerialName("requester") val requester: String? = null, - @SerialName("seasons") val seasons: List<Long>? = null, - @SerialName("source") val source: Source? = null, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("title") TITLE, - @SerialName("recommendation") RECOMMENDATION, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt deleted file mode 100644 index e1a6c63e..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestProgress.kt +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestProgress wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestProgress( - @SerialName("actor") val actor: Actor? = null, - @SerialName("awaiting_approval") val awaitingApproval: Boolean? = null, - @SerialName("blocking_stage") val blockingStage: BlockingStage? = null, - @SerialName("filed_by_you") val filedByYou: Boolean, - @SerialName("id") val id: String, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("on_behalf") val onBehalf: Boolean, - @SerialName("requested_at") val requestedAt: String? = null, - @SerialName("requester") val requester: Person, - @SerialName("seasons") val seasons: List<Long>?, - @SerialName("stages") val stages: List<ReadinessStage>?, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for blocking_stage. */ - @Serializable - enum class BlockingStage { - @SerialName("request") REQUEST, - @SerialName("acquisition") ACQUISITION, - @SerialName("visibility") VISIBILITY, - @SerialName("playback") PLAYBACK, - } - - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt deleted file mode 100644 index 900a2e63..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceipt.kt +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestReceipt wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestReceipt( - @SerialName("actor") val actor: RequestReceiptActor, - @SerialName("attempts") val attempts: Long, - @SerialName("coverage") val coverage: List<RequestReceiptCoverage>?, - @SerialName("created_at") val createdAt: String, - @SerialName("detail") val detail: String? = null, - @SerialName("id") val id: Long, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("membership") val membership: RequestReceiptMembership, - @SerialName("on_behalf") val onBehalf: Boolean, - @SerialName("recommendation_profile") val recommendationProfile: String? = null, - @SerialName("requester") val requester: String, - @SerialName("requester_username") val requesterUsername: String, - @SerialName("seasons") val seasons: List<Long>?, - @SerialName("seerr") val seerr: RequestReceiptSeerr, - @SerialName("source") val source: Source, - @SerialName("state") val state: State, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("updated_at") val updatedAt: String, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("title") TITLE, - @SerialName("recommendation") RECOMMENDATION, - } - - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("settled") SETTLED, - @SerialName("pending") PENDING, - @SerialName("retired") RETIRED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt deleted file mode 100644 index 5a0b3059..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptActor.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestReceiptActor wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestReceiptActor( - @SerialName("name") val name: String, - @SerialName("requester") val requester: String, - @SerialName("role") val role: Role, -) { - /** Wire values for role. */ - @Serializable - enum class Role { - @SerialName("admin") ADMIN, - @SerialName("household") HOUSEHOLD, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt deleted file mode 100644 index 80b87b51..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptCoverage.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestReceiptCoverage wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestReceiptCoverage( - @SerialName("outcome") val outcome: Outcome, - @SerialName("request_id") val requestId: Long? = null, - @SerialName("season") val season: Long, -) { - /** Wire values for outcome. */ - @Serializable - enum class Outcome { - @SerialName("requested") REQUESTED, - @SerialName("already_requested") ALREADY_REQUESTED, - @SerialName("already_held") ALREADY_HELD, - @SerialName("unresolved") UNRESOLVED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt deleted file mode 100644 index d5b005c4..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptMembership.kt +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestReceiptMembership wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestReceiptMembership( - @SerialName("bucket") val bucket: String? = null, - @SerialName("dispatch_log_id") val dispatchLogId: Long? = null, - @SerialName("status") val status: Status, -) { - /** Wire values for status. */ - @Serializable - enum class Status { - @SerialName("added") ADDED, - @SerialName("already_owned") ALREADY_OWNED, - @SerialName("pending_dispatch") PENDING_DISPATCH, - @SerialName("conflict") CONFLICT, - @SerialName("failed") FAILED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt deleted file mode 100644 index 638d156d..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/RequestReceiptSeerr.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** RequestReceiptSeerr wire contract from the pinned Conductarr schema. */ -@Serializable -data class RequestReceiptSeerr( - @SerialName("outcome") val outcome: Outcome, - @SerialName("request_id") val requestId: Long? = null, -) { - /** Wire values for outcome. */ - @Serializable - enum class Outcome { - @SerialName("requested") REQUESTED, - @SerialName("already_requested") ALREADY_REQUESTED, - @SerialName("not_needed") NOT_NEEDED, - @SerialName("not_attempted") NOT_ATTEMPTED, - @SerialName("unresolved") UNRESOLVED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt deleted file mode 100644 index 28b78b93..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Season.kt +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Season wire contract from the pinned Conductarr schema. */ -@Serializable -data class Season( - @SerialName("air_date") val airDate: String? = null, - @SerialName("coverage") val coverage: Coverage, - @SerialName("episode_count") val episodeCount: Long, - @SerialName("name") val name: String, - @SerialName("number") val number: Long, - @SerialName("overview") val overview: String, - @SerialName("recorded_episodes") val recordedEpisodes: Long, - @SerialName("request_state") val requestState: RequestState, - @SerialName("seerr_state") val seerrState: SeerrState, -) { - /** Wire values for coverage. */ - @Serializable - enum class Coverage { - @SerialName("none") NONE, - @SerialName("partial") PARTIAL, - @SerialName("complete") COMPLETE, - @SerialName("unknown") UNKNOWN, - } - - /** Wire values for request_state. */ - @Serializable - enum class RequestState { - @SerialName("none") NONE, - @SerialName("requested") REQUESTED, - @SerialName("unknown") UNKNOWN, - } - - /** Wire values for seerr_state. */ - @Serializable - enum class SeerrState { - @SerialName("untracked") UNTRACKED, - @SerialName("unknown") UNKNOWN, - @SerialName("pending") PENDING, - @SerialName("processing") PROCESSING, - @SerialName("partial") PARTIAL, - @SerialName("available") AVAILABLE, - @SerialName("blocklisted") BLOCKLISTED, - @SerialName("deleted") DELETED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt deleted file mode 100644 index 87ad626a..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonCoverage.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeasonCoverage wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeasonCoverage( - @SerialName("coverage") val coverage: Coverage, - @SerialName("season") val season: Long, -) { - /** Wire values for coverage. */ - @Serializable - enum class Coverage { - @SerialName("complete") COMPLETE, - @SerialName("partial") PARTIAL, - @SerialName("none") NONE, - @SerialName("unknown") UNKNOWN, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt deleted file mode 100644 index e8765ed2..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonDetail.kt +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeasonDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeasonDetail( - @SerialName("episodes") val episodes: List<Episode>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("observations") val observations: List<LibraryObservation>?, - @SerialName("season") val season: Season, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("tv") TV, - } - - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt deleted file mode 100644 index 9277b43d..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeasonGroup.kt +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeasonGroup wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeasonGroup( - @SerialName("episodes") val episodes: List<EpisodeRow>?, - @SerialName("season") val season: Long, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt deleted file mode 100644 index 9fe455af..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeedContribution.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeedContribution wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeedContribution( - @SerialName("added") val added: Boolean, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("watched") val watched: Boolean, -) { - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt deleted file mode 100644 index 80773fc8..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrObservation.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeerrObservation wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeerrObservation( - @SerialName("detail") val detail: String? = null, - @SerialName("requests") val requests: List<SeerrRequest>?, - @SerialName("state") val state: State, -) { - /** Wire values for state. */ - @Serializable - enum class State { - @SerialName("observed") OBSERVED, - @SerialName("unavailable") UNAVAILABLE, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt deleted file mode 100644 index ac6b7e08..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SeerrRequest.kt +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SeerrRequest wire contract from the pinned Conductarr schema. */ -@Serializable -data class SeerrRequest( - @SerialName("id") val id: Long, - @SerialName("status") val status: Status, -) { - /** Wire values for status. */ - @Serializable - enum class Status { - @SerialName("pending") PENDING, - @SerialName("approved") APPROVED, - @SerialName("declined") DECLINED, - @SerialName("failed") FAILED, - @SerialName("completed") COMPLETED, - @SerialName("unknown") UNKNOWN, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt deleted file mode 100644 index 8e30462b..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/SourceDetail.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** SourceDetail wire contract from the pinned Conductarr schema. */ -@Serializable -data class SourceDetail( - @SerialName("arr") val arr: String, - @SerialName("imported") val imported: String? = null, - @SerialName("indexer") val indexer: String? = null, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt deleted file mode 100644 index 29b35973..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/Title.kt +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** Title wire contract from the pinned Conductarr schema. */ -@Serializable -data class Title( - @SerialName("backdrop_url") val backdropUrl: String? = null, - @SerialName("genre_ids") val genreIds: List<Long>? = null, - @SerialName("held") val held: Held, - @SerialName("libraries") val libraries: List<String>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("overview") val overview: String, - @SerialName("poster_url") val posterUrl: String? = null, - @SerialName("release_date") val releaseDate: String? = null, - @SerialName("request_state") val requestState: RequestState, - @SerialName("seerr_state") val seerrState: SeerrState, - @SerialName("title") val title: String, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for held. */ - @Serializable - enum class Held { - @SerialName("held") HELD, - @SerialName("unheld") UNHELD, - @SerialName("unknown") UNKNOWN, - } - - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for request_state. */ - @Serializable - enum class RequestState { - @SerialName("none") NONE, - @SerialName("requested") REQUESTED, - @SerialName("unknown") UNKNOWN, - } - - /** Wire values for seerr_state. */ - @Serializable - enum class SeerrState { - @SerialName("untracked") UNTRACKED, - @SerialName("unknown") UNKNOWN, - @SerialName("pending") PENDING, - @SerialName("processing") PROCESSING, - @SerialName("partial") PARTIAL, - @SerialName("available") AVAILABLE, - @SerialName("blocklisted") BLOCKLISTED, - @SerialName("deleted") DELETED, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt deleted file mode 100644 index be3f0422..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleCast.kt +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleCast wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleCast( - @SerialName("credit_context") val creditContext: CreditContext, - @SerialName("items") val items: List<CastMember>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("metadata_state") val metadataState: MetadataState, - @SerialName("paging") val paging: Paging, - @SerialName("source") val source: Source, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for credit_context. */ - @Serializable - enum class CreditContext { - @SerialName("movie-character") MOVIE_CHARACTER, - @SerialName("tv-leading-character") TV_LEADING_CHARACTER, - } - - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for metadata_state. */ - @Serializable - enum class MetadataState { - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for paging. */ - @Serializable - enum class Paging { - @SerialName("unpaged") UNPAGED, - } - - /** Wire values for source. */ - @Serializable - enum class Source { - @SerialName("seerr") SEERR, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt deleted file mode 100644 index 38b7aac9..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleMetadata.kt +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleMetadata wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleMetadata( - @SerialName("checked_at") val checkedAt: String? = null, - @SerialName("genre_state") val genreState: GenreState, - @SerialName("genres") val genres: List<Genre>?, - @SerialName("genres_observed_at") val genresObservedAt: String? = null, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("provider_state") val providerState: ProviderState, - @SerialName("providers") val providers: List<Provider>?, - @SerialName("providers_observed_at") val providersObservedAt: String? = null, - @SerialName("region") val region: Region, - @SerialName("tmdb_id") val tmdbId: Long, -) { - /** Wire values for genre_state. */ - @Serializable - enum class GenreState { - @SerialName("unobserved") UNOBSERVED, - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } - - /** Wire values for provider_state. */ - @Serializable - enum class ProviderState { - @SerialName("unobserved") UNOBSERVED, - @SerialName("observed") OBSERVED, - @SerialName("missing") MISSING, - @SerialName("unavailable") UNAVAILABLE, - } - - /** Wire values for region. */ - @Serializable - enum class Region { - @SerialName("NL") NL, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt deleted file mode 100644 index fa754de0..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRatings.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleRatings wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleRatings( - @SerialName("ratings") val ratings: List<Rating>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt deleted file mode 100644 index ffdba7f6..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleRequestsBody.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleRequestsBody wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleRequestsBody( - @SerialName("requests") val requests: List<RequestReceipt>?, -) diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt deleted file mode 100644 index de1c7feb..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/TitleVersion.kt +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** TitleVersion wire contract from the pinned Conductarr schema. */ -@Serializable -data class TitleVersion( - @SerialName("file") val file: FileDetail? = null, - @SerialName("role") val role: Role, - @SerialName("seasons") val seasons: List<SeasonGroup>? = null, - @SerialName("source") val source: String, -) { - /** Wire values for role. */ - @Serializable - enum class Role { - @SerialName("primary") PRIMARY, - @SerialName("mirror") MIRROR, - } -} diff --git a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt b/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt deleted file mode 100644 index 5828e51f..00000000 --- a/conductarr-client/src/main/kotlin/org/justinvisser/wolfskin/conductarr/model/ViewerReadiness.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** ViewerReadiness wire contract from the pinned Conductarr schema. */ -@Serializable -data class ViewerReadiness( - @SerialName("blocking_stage") val blockingStage: BlockingStage? = null, - @SerialName("coverage") val coverage: List<SeasonCoverage>?, - @SerialName("media_type") val mediaType: MediaType, - @SerialName("open_link") val openLink: OpenLink, - @SerialName("seerr") val seerr: SeerrObservation, - @SerialName("sources") val sources: List<EvidenceSource>?, - @SerialName("stages") val stages: List<ReadinessStage>?, - @SerialName("title") val title: String? = null, - @SerialName("tmdb_id") val tmdbId: Long, - @SerialName("viewer") val viewer: Person, -) { - /** Wire values for blocking_stage. */ - @Serializable - enum class BlockingStage { - @SerialName("request") REQUEST, - @SerialName("acquisition") ACQUISITION, - @SerialName("visibility") VISIBILITY, - @SerialName("playback") PLAYBACK, - } - - /** Wire values for media_type. */ - @Serializable - enum class MediaType { - @SerialName("movie") MOVIE, - @SerialName("tv") TV, - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt deleted file mode 100644 index 4e51ba98..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/CacheTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.nio.file.Files -import org.junit.Assert.* -import org.junit.Test - -/** Lifetime boundaries and storage behavior do not depend on wall-clock sleeps. */ -class CacheTest { - @Test fun `per endpoint lifetime boundaries preserve expired LKG for failure fallback`() { - val entry = CacheEntry("body", "tag", 1_000, 1_000, expiresAtMillis = 5_000, group = "group") - val rows = listOf( - Triple(CachePolicy.DAILY, ReadTrigger.IF_STALE, 86_400_000L), - Triple(CachePolicy.DISCOVERY, ReadTrigger.IF_STALE, 1_800_000L), - Triple(CachePolicy.RATINGS, ReadTrigger.IF_STALE, 21_600_000L), - Triple(CachePolicy.SEASON, ReadTrigger.IF_STALE, 21_600_000L), - Triple(CachePolicy.ACTIVITY, ReadTrigger.POLL, 30_000L), - ) - for ((policy, trigger, lifetime) in rows) { - assertFalse(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime - 1)) - assertTrue(policy.name, policy.shouldRefresh(trigger, entry, 1_000 + lifetime)) - } - assertFalse(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 4_999)) - assertTrue(CachePolicy.FEED.shouldRefresh(ReadTrigger.IF_STALE, entry, 5_000)) - assertFalse(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.IF_STALE, entry, Long.MAX_VALUE)) - assertTrue(CachePolicy.LAUNCH.shouldRefresh(ReadTrigger.LAUNCH, entry, 1_001)) - assertTrue(CachePolicy.FOREGROUND.shouldRefresh(ReadTrigger.FOREGROUND, entry, 1_001)) - assertTrue(CachePolicy.ON_OPEN.shouldRefresh(ReadTrigger.OPEN, entry, 1_001)) - assertTrue(CachePolicy.SEASON.shouldRefresh(ReadTrigger.OPEN, entry, 1_001)) - assertFalse(CachePolicy.DAILY.shouldRefresh(ReadTrigger.CACHE_ONLY, null, Long.MAX_VALUE)) - } - - @Test fun `disk and memory retain newest group entries while isolating other identities and media`() { - val directory = Files.createTempDirectory("conductarr-capacity-").toFile() - try { - for (store in listOf(MemoryCacheStore(), FileCacheStore(directory))) { - for (index in 0..300) { - store.write("person-a", "movies:$index", CacheEntry("$index", null, index.toLong(), index.toLong(), group = "movies"), 300) - } - store.write("person-a", "tv:1", CacheEntry("tv", null, 0, 0, group = "tv"), 300) - store.write("person-b", "movies:0", CacheEntry("other", null, 0, 0, group = "movies"), 300) - assertNull(store.read("person-a", "movies:0")) - assertEquals("300", store.read("person-a", "movies:300")!!.body) - assertEquals("tv", store.read("person-a", "tv:1")!!.body) - store.clear("person-a") - assertNull(store.read("person-a", "tv:1")) - assertEquals("other", store.read("person-b", "movies:0")!!.body) - } - } finally { directory.deleteRecursively() } - } - - @Test fun `corrupt saved entries become a cache miss rather than blocking recovery`() { - val directory = Files.createTempDirectory("conductarr-corrupt-").toFile() - try { - val store = FileCacheStore(directory) - store.write("person", "profiles:1", CacheEntry("valid", null, 0, 0, group = "profiles")) - directory.walkTopDown().first { it.extension == "json" }.writeText("truncated{") - assertNull(FileCacheStore(directory).read("person", "profiles:1")) - val repaired = CacheEntry("new", null, 1, 1, group = "profiles") - store.write("person", "profiles:1", repaired) - assertEquals(repaired, FileCacheStore(directory).read("person", "profiles:1")) - } finally { directory.deleteRecursively() } - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt deleted file mode 100644 index 98173fce..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientSmoke.kt +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.nio.file.Files -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer - -/** Exercise the production facade with a real local socket and a reopened disk cache. */ -fun main() = runBlocking { - val directory = Files.createTempDirectory("conductarr-smoke-").toFile() - try { - MockWebServer().use { server -> - server.start() - val baseUrl = server.url("/").toString() - server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"smoke\"").build()) - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> - val live = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Value - check(live.saved.value!!.profiles!!.single().name == "Justin") - } - ConductarrClient(baseUrl, TEST_TOKEN, FileCacheStore(directory)).use { client -> - val saved = client.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value - check(saved.saved.fromDisk) - server.enqueue(MockResponse.Builder().code(304).build()) - val revalidated = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Value - check(revalidated.saved.value == saved.saved.value) - server.enqueue(MockResponse.Builder().code(503).body("offline").build()) - val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure - check(failure.error == ClientError.Http(503)) - check(failure.saved!!.value!!.defaultProfile == "justin") - println("SMOKE: live profile Justin -> disk reopen -> ETag 304 -> HTTP 503 retains Justin; requests=${server.requestCount}") - } - } - } finally { directory.deleteRecursively() } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt deleted file mode 100644 index 937543a9..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ClientTest.kt +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import java.io.File -import java.nio.file.Files -import java.util.logging.Handler -import java.util.logging.LogRecord -import java.util.logging.Logger -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import mockwebserver3.MockResponse -import mockwebserver3.MockWebServer -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.model.* - -internal const val TEST_TOKEN = "0123456789abcdef0123456789abcdef" -internal const val PROFILES_JSON = """{"profiles":[{"key":"justin","name":"Justin"}],"default_profile":"justin"}""" - -/** Local network tests exercise identity, conditional reads and durable failure recovery. */ -class ClientTest { - private lateinit var server: MockWebServer - private val clients = mutableListOf<ConductarrClient>() - private lateinit var directory: File - private var now = 1_700_000_000_000L - - @Before fun setUp() { - server = MockWebServer() - server.start() - directory = Files.createTempDirectory("conductarr-test-").toFile() - } - - @After fun tearDown() { - clients.forEach { it.close() } - server.close() - directory.deleteRecursively() - } - - @Test fun `401 means sign in clears saved identity and never returns stale fallback`() = runBlocking { - val cache = FileCacheStore(directory) - val client = client(cache) - server.enqueue(response(PROFILES_JSON)) - client.read(TvRoutes.profiles()).toList() - server.enqueue(response("unauthorized", 401)) - val results = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList() - assertEquals("Justin", (results.first() as ReadResult.Value).saved.value!!.profiles!!.first().name) - val failure = results.last() as ReadResult.Failure - assertEquals(ClientError.NeedsSignIn, failure.error) - assertNull(failure.saved) - assertEquals(listOf(ReadResult.Failure<RecommendationProfiles>(ClientError.NeedsSignIn)), client.read(TvRoutes.profiles()).toList()) - server.enqueue(response("unauthorized", 401)) - val reopened = client(cache).read(TvRoutes.profiles()).toList() - assertEquals(1, reopened.size) - assertEquals(ClientError.NeedsSignIn, (reopened.single() as ReadResult.Failure).error) - assertEquals(3, server.requestCount) - } - - @Test fun `ETag 304 reuses body across disk reopen without changing saved time`() = runBlocking { - val first = client(FileCacheStore(directory)) - server.enqueue(MockResponse.Builder().body(PROFILES_JSON).addHeader("ETag", "\"profiles-v1\"").build()) - val original = (first.read(TvRoutes.profiles()).toList().single() as ReadResult.Value).saved - val firstRequest = server.takeRequest() - assertEquals("MediaBrowser Token=\"$TEST_TOKEN\"", firstRequest.headers["Authorization"]) - assertEquals("/api/v1/recommendations/profiles", firstRequest.url.encodedPath) - now += 60_000 - val reopened = client(FileCacheStore(directory)) - val painted = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).first() as ReadResult.Value - assertEquals(original.value, painted.saved.value) - assertTrue(painted.saved.fromDisk) - assertEquals(1, server.requestCount) - server.enqueue(response("", 304)) - val results = reopened.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList() - assertEquals(2, results.size) - val validated = (results.last() as ReadResult.Value).saved - assertEquals(original.value, validated.value) - assertEquals(original.savedAtMillis, validated.savedAtMillis) - assertFalse(validated.fromDisk) - assertEquals("\"profiles-v1\"", server.takeRequest().headers["If-None-Match"]) - } - - @Test fun `HTTP and invalid JSON failures preserve last known good on disk`() = runBlocking { - val cache = FileCacheStore(directory) - server.enqueue(response(PROFILES_JSON)) - client(cache).read(TvRoutes.profiles()).toList() - for ((body, status, expected) in listOf( - Triple("down", 503, ClientError.Http(503)), - Triple("{bad json", 200, ClientError.InvalidResponse), - )) { - server.enqueue(response(body, status)) - val result = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure - assertEquals(expected, result.error) - assertEquals("justin", result.saved!!.value!!.defaultProfile) - } - val saved = client(FileCacheStore(directory)).read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).first() as ReadResult.Value - assertEquals("Justin", saved.saved.value!!.profiles!!.single().name) - } - - @Test fun `network failure retains LKG and maps to unreachable`() = runBlocking { - val client = client() - server.enqueue(response(PROFILES_JSON)) - client.read(TvRoutes.profiles()).toList() - server.close() - val failure = client.read(TvRoutes.profiles(), ReadTrigger.LAUNCH).toList().last() as ReadResult.Failure - assertEquals(ClientError.Unreachable, failure.error) - assertEquals("justin", failure.saved!!.value!!.defaultProfile) - } - - @Test fun `identity partitions saved data by token and base URL`() = runBlocking { - val cache = FileCacheStore(directory) - server.enqueue(response(PROFILES_JSON)) - client(cache).read(TvRoutes.profiles()).toList() - val switched = client(cache, "fedcba9876543210fedcba9876543210") - assertTrue(switched.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - val otherServer = ConductarrClient(server.url("/other/").toString(), TEST_TOKEN, cache).also { clients.add(it) } - assertTrue(otherServer.read(TvRoutes.profiles(), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - assertFalse(directory.walkTopDown().filter { it.isFile }.any { it.readText().contains(TEST_TOKEN) || it.path.contains(TEST_TOKEN) }) - } - - @Test fun `ratings 404 is cached as none for six hours but server failure is not cached`() = runBlocking { - val client = client() - val call = TvRoutes.ratings(42, MediaType.MOVIE) - server.enqueue(response("missing", 404)) - assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) - now += 6 * 60 * 60 * 1000L - 1 - assertTrue((client.read(call).toList().single() as ReadResult.Value).saved.absent) - assertEquals(1, server.requestCount) - now++ - server.enqueue(response("down", 503)) - assertEquals(ClientError.Http(503), (client.read(call).toList().last() as ReadResult.Failure).error) - server.enqueue(response("missing", 404)) - client.read(call).toList() - assertEquals(3, server.requestCount) - } - - @Test fun `mutation is sent once without caller controlled actor or automatic replay`() = runBlocking { - val client = client() - val body = RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel") - server.enqueue(response("uncertain", 503)) - assertEquals(MutationResult.Failure(ClientError.Http(503)), client.mutate(TvRoutes.request(body))) - assertEquals(1, server.requestCount) - val request = server.takeRequest() - assertEquals("POST", request.method) - val sent = WireJson.parseToJsonElement(request.body!!.utf8()).toString() - assertTrue(sent.contains("\"requester\":\"mindel\"")) - assertFalse(sent.contains("actor")) - assertFalse(sent.contains("seasons")) - assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - } - - @Test fun `diagnostics and errors never log token or hostile response body`() = runBlocking { - val events = mutableListOf<ClientEvent>() - val logged = StringBuilder() - val handler = object : Handler() { - override fun publish(record: LogRecord) { logged.append(record.message).append(record.thrown?.toString().orEmpty()) } - override fun flush() = Unit - override fun close() = Unit - } - val logger = Logger.getLogger("") - logger.addHandler(handler) - try { - val client = ConductarrClient(server.url("/").toString(), TEST_TOKEN, MemoryCacheStore(), onEvent = { events.add(it) }).also { clients.add(it) } - server.enqueue(response("hostile response $TEST_TOKEN", 500)) - val error = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure - assertEquals(ClientError.Http(500), error.error) - server.enqueue(response("$TEST_TOKEN", 401)) - val unauthorized = client.read(TvRoutes.profiles()).toList().single() as ReadResult.Failure - assertEquals(ClientError.NeedsSignIn, unauthorized.error) - assertEquals(listOf(ClientEvent.HTTP_FAILURE, ClientEvent.NEEDS_SIGN_IN), events) - assertFalse((logged.toString() + events + error + unauthorized).contains(TEST_TOKEN)) - } finally { logger.removeHandler(handler) } - } - - @Test fun `feed honours server expiry and encodes profile as one path segment`() = runBlocking { - val client = client() - val expiry = java.time.Instant.ofEpochMilli(now + 10_000).toString() - val feed = """{"profile":{"key":"justin","name":"Justin"},"projection":{"coverage":{"complete":true,"excluded":0,"matched":0,"total":0,"unresolved":0},"facts":null,"genre_id":0,"media_type":"all","service_id":0,"unresolved":null},"state":"current","outcome":"empty","materialization":"current","movies":null,"shows":null,"expires_at":"$expiry"}""" - server.enqueue(MockResponse.Builder().body(feed).addHeader("ETag", "\"feed-v1\"").build()) - val call = TvRoutes.feed("justin/a b") - client.read(call).toList() - now += 9_999 - assertEquals(RecommendationFeed.Outcome.EMPTY, (client.read(call).toList().single() as ReadResult.Value).saved.value!!.outcome) - assertEquals(1, server.requestCount) - val request = server.takeRequest() - assertEquals("/api/v1/recommendations/profiles/justin%2Fa%20b/feed", request.url.encodedPath) - now++ - server.enqueue(response("", 304)) - val result = client.read(call).toList().last() as ReadResult.Value - assertEquals(RecommendationFeed.Outcome.EMPTY, result.saved.value!!.outcome) - assertEquals("\"feed-v1\"", server.takeRequest().headers["If-None-Match"]) - } - - @Test fun `successful request returns typed receipt and title requests never use cache`() = runBlocking { - val receipt = """{"actor":{"name":"Justin","requester":"justin","role":"household"},"attempts":1,"coverage":null,"created_at":"2026-11-05T12:00:00Z","id":7,"media_type":"movie","membership":{"status":"added"},"on_behalf":true,"requester":"mindel","requester_username":"Mindel","seasons":null,"seerr":{"outcome":"requested","request_id":9},"source":"title","state":"settled","tmdb_id":42,"updated_at":"2026-11-05T12:00:00Z"}""" - val client = client() - server.enqueue(response(receipt)) - val result = client.mutate(TvRoutes.request(RequestIntentBody(mediaType = RequestIntentBody.MediaType.MOVIE, tmdbId = 42, requester = "mindel"))) as MutationResult.Success - assertEquals(7L, result.value.id) - assertEquals("justin", result.value.actor.requester) - assertTrue(result.value.onBehalf) - server.enqueue(response("""{"requests":[$receipt]}""")) - val receipts = client.read(TvRoutes.requests(42, MediaType.MOVIE)).toList().single() as ReadResult.Value - assertEquals(7L, receipts.saved.value!!.requests!!.single().id) - assertTrue(client.read(TvRoutes.requests(42, MediaType.MOVIE), ReadTrigger.CACHE_ONLY).toList().isEmpty()) - } - - private fun client(cache: CacheStore = MemoryCacheStore(), token: String = TEST_TOKEN): ConductarrClient = - ConductarrClient(server.url("/").toString(), token, cache, nowMillis = { now }).also { clients.add(it) } - - private fun response(body: String, status: Int = 200): MockResponse = MockResponse.Builder().code(status).body(body).build() -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt deleted file mode 100644 index fcebb09f..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ContractTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -@file:OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) -package org.justinvisser.wolfskin.conductarr - -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.SerialKind -import kotlinx.serialization.descriptors.StructureKind -import kotlinx.serialization.json.* -import org.junit.Assert.* -import org.junit.Test -import org.justinvisser.wolfskin.conductarr.model.ErrorDetail - -/** Schema structure, constraints and route coverage are independent of runtime transport tests. */ -class ContractTest { - private val document = resource("openapi.json") - private val schemas = document["components"]!!.jsonObject["schemas"]!!.jsonObject - private val serializers = modelSerializers.associateBy { it.descriptor.serialName.substringAfterLast('.') } - - @Test fun `every maintained model matches pinned schema fields types requiredness and enums`() { - for ((name, serializer) in serializers) { - compare(serializer.descriptor, schemas.getValue(name).jsonObject, name) - } - } - - @Test fun `all reviewed schema constraints fail on drift including formats limits and defaults`() { - val reviewed = resource("model-contracts.json") - assertEquals(serializers.keys, reviewed.keys) - for (name in reviewed.keys) assertEquals("Schema constraint drift: $name", reviewed[name], schemas[name]) - } - - @Test fun `all twenty two TV operations exist with their typed request and response contracts`() { - val expected = setOf( - "GET /api/v1/operator", "GET /api/v1/recommendations/profiles", - "GET /api/v1/recommendations/profiles/{profile}/feed", - "GET /api/v1/recommendations/profiles/{profile}/dismissals", - "POST /api/v1/recommendations/profiles/{profile}/dismissals", - "DELETE /api/v1/recommendations/profiles/{profile}/dismissals/{type}/{tmdb}", - "GET /catalogue/options", "GET /catalogue/discover", - "GET /catalogue/titles/{tmdb}/{type}", "GET /catalogue/titles/{tmdb}/{type}/cast", - "GET /catalogue/titles/{tmdb}/{type}/related", "GET /catalogue/titles/{tmdb}/{type}/enrichment", - "GET /catalogue/titles/{tmdb}/{type}/ratings", "GET /catalogue/titles/{tmdb}/{type}/display", - "GET /catalogue/tv/{tmdb}/seasons/{season}", "GET /catalogue/performers/{performer}", - "GET /catalogue/performers/{performer}/filmography", - "GET /api/v1/activity", "GET /api/v1/activity/titles/{tmdb}/{type}", - "GET /api/v1/requests/titles/{tmdb}/{type}", "POST /api/v1/requests", - "GET /curation/library-audiences", - ) - assertEquals(expected, TvRoutes.contracts.map { "${it.method} ${it.path}" }.toSet()) - assertEquals(expected.size, TvRoutes.contracts.size) - val operations = setOf( - "get-cockpit-operator", "get-recommendation-profiles", "get-library-audiences", - "get-recommendation-feed", "get-catalogue-options", "get-catalogue-discover", - "get-recommendation-dismissals", "post-recommendation-dismissal", "delete-recommendation-dismissal", - "get-catalogue-title", "get-catalogue-cast", "get-catalogue-related", "get-catalogue-season", - "get-catalogue-enrichment", "get-catalogue-display", "get-activity-title", "get-title-requests", - "get-catalogue-performer", "get-catalogue-filmography", "post-request-intent", "get-activity", - "get-catalogue-ratings", - ) - assertEquals(operations, TvRoutes.contracts.map { route -> - document.getValue("paths").jsonObject.getValue(route.path).jsonObject - .getValue(route.method.lowercase()).jsonObject.getValue("operationId").jsonPrimitive.content - }.toSet()) - val paths = document["paths"]!!.jsonObject - for (route in TvRoutes.contracts) { - val operation = paths.getValue(route.path).jsonObject.getValue(route.method.lowercase()).jsonObject - val response = operation["responses"]!!.jsonObject["200"]!!.jsonObject - assertEquals(route.responseSchema, schemaReference(response)) - assertTrue("Missing response model ${route.responseSchema}", serializers.containsKey(route.responseSchema)) - if (route.requestSchema != null) { - assertEquals(route.requestSchema, schemaReference(operation["requestBody"]!!.jsonObject)) - assertTrue(serializers.containsKey(route.requestSchema)) - } - } - } - - @Test fun `unconstrained error values preserve objects arrays scalars and null`() { - for (value in listOf("{\"nested\":[1,null,true]}", "[1,\"x\"]", "42", "true", "null")) { - val detail = WireJson.decodeFromString<ErrorDetail>("{\"value\":$value}") - assertEquals(Json.parseToJsonElement(value), detail.value ?: JsonNull) - } - } - - private fun compare(descriptor: SerialDescriptor, schema: JsonObject, location: String) { - schema["\$ref"]?.let { - val name = it.jsonPrimitive.content.substringAfterLast('/') - assertEquals(location, name, descriptor.serialName.substringAfterLast('.').removeSuffix("?")) - assertTrue("Unregistered referenced model $name", serializers.containsKey(name)) - return - } - if (schema["type"] == null) { - assertTrue(location, descriptor.serialName.startsWith("kotlinx.serialization.json.JsonElement")) - return - } - schema["enum"]?.let { values -> - assertEquals(location, SerialKind.ENUM, descriptor.kind) - assertEquals(location, values.jsonArray.map { it.jsonPrimitive.content }.toSet(), - (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()) - return - } - val types = when (val type = schema["type"]!!) { - is JsonArray -> type.map { it.jsonPrimitive.content } - else -> listOf(type.jsonPrimitive.content) - } - when (types.first { it != "null" }) { - "string" -> assertEquals(location, PrimitiveKind.STRING, descriptor.kind) - "integer" -> assertEquals(location, PrimitiveKind.LONG, descriptor.kind) - "number" -> assertEquals(location, PrimitiveKind.DOUBLE, descriptor.kind) - "boolean" -> assertEquals(location, PrimitiveKind.BOOLEAN, descriptor.kind) - "array" -> { - assertEquals(location, StructureKind.LIST, descriptor.kind) - compare(descriptor.getElementDescriptor(0), schema["items"]!!.jsonObject, "$location[]") - } - "object" -> if (schema["additionalProperties"] is JsonObject) { - assertEquals(location, StructureKind.MAP, descriptor.kind) - assertEquals(location, PrimitiveKind.STRING, descriptor.getElementDescriptor(0).kind) - compare(descriptor.getElementDescriptor(1), schema["additionalProperties"]!!.jsonObject, "$location{}") - } else compareFields(descriptor, schema, location) - else -> error("Unhandled schema type at $location") - } - } - - private fun compareFields(descriptor: SerialDescriptor, schema: JsonObject, location: String) { - assertEquals(location, StructureKind.CLASS, descriptor.kind) - val properties = schema["properties"]!!.jsonObject.filterValues { it.jsonObject["readOnly"] != JsonPrimitive(true) } - val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }?.toSet().orEmpty() - assertEquals(location, properties.keys, (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()) - for ((name, property) in properties) { - val index = descriptor.getElementIndex(name) - val field = descriptor.getElementDescriptor(index) - assertEquals("Requiredness drift: $location.$name", name !in required, descriptor.isElementOptional(index)) - val type = property.jsonObject["type"] - if (type is JsonArray) assertTrue("Nullable collection drift: $location.$name", field.isNullable) - else if (name in required) assertFalse("Nullability drift: $location.$name", field.isNullable) - compare(field, property.jsonObject, "$location.$name") - } - } - - private fun schemaReference(container: JsonObject): String = container.getValue("content").jsonObject - .getValue("application/json").jsonObject.getValue("schema").jsonObject.getValue("\$ref").jsonPrimitive.content.substringAfterLast('/') - - private fun resource(name: String): JsonObject = javaClass.classLoader.getResourceAsStream(name)!!.use { - Json.parseToJsonElement(it.bufferedReader().readText()).jsonObject - } -} diff --git a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt b/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt deleted file mode 100644 index a7384768..00000000 --- a/conductarr-client/src/test/kotlin/org/justinvisser/wolfskin/conductarr/ModelContracts.kt +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.conductarr - -import kotlinx.serialization.KSerializer -import org.justinvisser.wolfskin.conductarr.model.* - -/** Independently enumerated fallback models checked against the vendored schemas. */ -internal val modelSerializers: List<KSerializer<*>> = listOf( - ActingCredit.serializer(), - ActivityFeed.serializer(), - Actor.serializer(), - Candidate.serializer(), - CastMember.serializer(), - Detail.serializer(), - Discovery.serializer(), - Display.serializer(), - Enrichment.serializer(), - Episode.serializer(), - EpisodeFileChip.serializer(), - EpisodeRow.serializer(), - EpisodeView.serializer(), - ErrorDetail.serializer(), - ErrorModel.serializer(), - EvidenceSource.serializer(), - FileDetail.serializer(), - FilmographyPage.serializer(), - FilmographyTitle.serializer(), - Genre.serializer(), - HealthDetail.serializer(), - HeldTitle.serializer(), - ItemDetail.serializer(), - LibraryAudience.serializer(), - LibraryAudiencesBody.serializer(), - LibraryObservation.serializer(), - OpenLink.serializer(), - Operator.serializer(), - OperatorCapabilities.serializer(), - Option.serializer(), - Options.serializer(), - Page.serializer(), - PerformerDetail.serializer(), - Person.serializer(), - Provider.serializer(), - Rating.serializer(), - ReadProfile.serializer(), - ReadinessStage.serializer(), - RecommendationCoverage.serializer(), - RecommendationDismissal.serializer(), - RecommendationDismissals.serializer(), - RecommendationFact.serializer(), - RecommendationFeed.serializer(), - RecommendationHideInputBody.serializer(), - RecommendationHideResult.serializer(), - RecommendationProfiles.serializer(), - RecommendationProjection.serializer(), - RecommendationRestoreResult.serializer(), - RequestIntentBody.serializer(), - RequestProgress.serializer(), - RequestReceipt.serializer(), - RequestReceiptActor.serializer(), - RequestReceiptCoverage.serializer(), - RequestReceiptMembership.serializer(), - RequestReceiptSeerr.serializer(), - Season.serializer(), - SeasonCoverage.serializer(), - SeasonDetail.serializer(), - SeasonGroup.serializer(), - SeedContribution.serializer(), - SeerrObservation.serializer(), - SeerrRequest.serializer(), - SourceDetail.serializer(), - Title.serializer(), - TitleCast.serializer(), - TitleMetadata.serializer(), - TitleRequestsBody.serializer(), - TitleRatings.serializer(), - TitleVersion.serializer(), - ViewerReadiness.serializer(), -) diff --git a/conductarr-client/src/test/resources/model-contracts.json b/conductarr-client/src/test/resources/model-contracts.json deleted file mode 100644 index 41a0b411..00000000 --- a/conductarr-client/src/test/resources/model-contracts.json +++ /dev/null @@ -1,3255 +0,0 @@ -{ - "ActingCredit": { - "additionalProperties": false, - "properties": { - "character": { - "type": "string" - }, - "credit_id": { - "type": "string" - }, - "episode_count": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "credit_id", - "character" - ], - "type": "object" - }, - "ActivityFeed": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ActivityFeed.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/RequestProgress" - }, - "type": [ - "array", - "null" - ] - }, - "requester": { - "description": "Household key the feed is attributed to, or all for the household", - "type": "string" - }, - "sources": { - "items": { - "$ref": "#/components/schemas/EvidenceSource" - }, - "type": [ - "array", - "null" - ] - }, - "truncated": { - "description": "More recent requests exist than the window returned", - "type": "boolean" - } - }, - "required": [ - "requester", - "items", - "truncated", - "sources" - ], - "type": "object" - }, - "Actor": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "requester": { - "description": "The acting operator's own household requester key", - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - } - }, - "required": [ - "name", - "role", - "requester" - ], - "type": "object" - }, - "Candidate": { - "additionalProperties": false, - "properties": { - "backdrop_path": { - "description": "TMDB-relative landscape artwork path; absent when none is observed", - "type": "string" - }, - "contributions": { - "items": { - "$ref": "#/components/schemas/SeedContribution" - }, - "type": [ - "array", - "null" - ] - }, - "genre_ids": { - "description": "Observed candidate genre IDs; absence does not prove no genres", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "overview": { - "type": "string" - }, - "poster_path": { - "type": "string" - }, - "score": { - "format": "double", - "type": "number" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "tvdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tvdb_id", - "title", - "overview", - "poster_path", - "year", - "score", - "contributions", - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "CastMember": { - "additionalProperties": false, - "properties": { - "character": { - "type": "string" - }, - "credit_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "order": { - "format": "int64", - "type": "integer" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "profile_url": { - "type": "string" - } - }, - "required": [ - "performer_id", - "name", - "character", - "credit_id", - "order" - ], - "type": "object" - }, - "Detail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Detail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "held_title": { - "$ref": "#/components/schemas/HeldTitle" - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - }, - "runtime": { - "format": "int64", - "type": "integer" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/Season" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - }, - "title": { - "$ref": "#/components/schemas/Title" - } - }, - "required": [ - "state", - "title", - "metadata_state", - "genres", - "seasons", - "metadata", - "observations" - ], - "type": "object" - }, - "Discovery": { - "additionalProperties": false, - "properties": { - "genre_id": { - "format": "int64", - "type": "integer" - }, - "next": { - "description": "Same selected query's next public page, bounded at page 500; absence alone does not prove catalogue exhaustion", - "type": "string" - }, - "region": { - "description": "Comma-separated selected service regions; NL for unfiltered discovery", - "type": "string" - }, - "service_id": { - "format": "int64", - "type": "integer" - }, - "sort": { - "enum": [ - "trending", - "popular", - "new", - "top", - "upcoming" - ], - "type": "string" - }, - "state": { - "description": "Empty is an answered zero-match lane; exhausted is an empty page beyond upstream total_pages; partial cannot prove absence", - "enum": [ - "observed", - "empty", - "partial", - "exhausted" - ], - "type": "string" - } - }, - "required": [ - "genre_id", - "service_id", - "sort", - "region", - "state" - ], - "type": "object" - }, - "Enrichment": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Enrichment.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - }, - "runtime": { - "format": "int64", - "type": "integer" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/Season" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - }, - "title": { - "$ref": "#/components/schemas/Title" - } - }, - "required": [ - "state", - "title", - "metadata_state", - "genres", - "seasons", - "metadata", - "observations", - "ratings" - ], - "type": "object" - }, - "Episode": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "name": { - "type": "string" - }, - "number": { - "format": "int64", - "type": "integer" - }, - "overview": { - "type": "string" - }, - "recorded": { - "enum": [ - "recorded", - "missing", - "unknown" - ], - "type": "string" - }, - "vote_average": { - "description": "Provider episode score on a 0\u201310 scale; absence is not zero.", - "format": "double", - "type": "number" - }, - "vote_count": { - "description": "Provider episode vote count; explicit zero is retained.", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "number", - "name", - "overview", - "recorded" - ], - "type": "object" - }, - "EpisodeFileChip": { - "additionalProperties": false, - "properties": { - "quality": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "size_bytes" - ], - "type": "object" - }, - "EpisodeRow": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "episode": { - "format": "int64", - "type": "integer" - }, - "file": { - "$ref": "#/components/schemas/EpisodeFileChip" - }, - "has_file": { - "type": "boolean" - }, - "key": { - "description": "SxxEyy", - "type": "string" - }, - "monitored": { - "type": "boolean" - }, - "queue_state": { - "description": "Arr queue status for this exact episode; omitted when no matching queue row was observed", - "type": "string" - }, - "runtime_minutes": { - "description": "Sonarr episode runtime in minutes; omitted when Sonarr reports none", - "format": "int64", - "type": "integer" - }, - "season": { - "format": "int64", - "type": "integer" - }, - "title": { - "type": "string" - }, - "watched": { - "additionalProperties": { - "type": "boolean" - }, - "type": "object" - } - }, - "required": [ - "season", - "episode", - "key", - "monitored", - "has_file", - "watched" - ], - "type": "object" - }, - "EpisodeView": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/EpisodeView.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/SeasonGroup" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "users": { - "description": "jellyfin usernames whose sees: matrix covers this item (watch-chip columns)", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "tmdb_id", - "title", - "owners", - "users", - "seasons" - ], - "type": "object" - }, - "ErrorDetail": { - "additionalProperties": false, - "properties": { - "location": { - "description": "Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'", - "type": "string" - }, - "message": { - "description": "Error message text", - "type": "string" - }, - "value": { - "description": "The value at the given location" - } - }, - "type": "object" - }, - "ErrorModel": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ErrorModel.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "description": "A human-readable explanation specific to this occurrence of the problem.", - "examples": [ - "Property foo is required but is missing." - ], - "type": "string" - }, - "errors": { - "description": "Optional list of individual error details", - "items": { - "$ref": "#/components/schemas/ErrorDetail" - }, - "type": [ - "array", - "null" - ] - }, - "instance": { - "description": "A URI reference that identifies the specific occurrence of the problem.", - "examples": [ - "https://example.com/error-log/abc123" - ], - "format": "uri", - "type": "string" - }, - "status": { - "description": "HTTP status code", - "examples": [ - 400 - ], - "format": "int64", - "type": "integer" - }, - "title": { - "description": "A short, human-readable summary of the problem type. This value should not change between occurrences of the error.", - "examples": [ - "Bad Request" - ], - "type": "string" - }, - "type": { - "default": "about:blank", - "description": "A URI reference to human-readable documentation for the error.", - "examples": [ - "https://example.com/errors/example" - ], - "format": "uri", - "type": "string" - } - }, - "type": "object" - }, - "EvidenceSource": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "name": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "name", - "state" - ], - "type": "object" - }, - "FileDetail": { - "additionalProperties": false, - "properties": { - "audio": { - "type": "string" - }, - "container": { - "type": "string" - }, - "quality": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "resolution": { - "type": "string" - }, - "size_bytes": { - "format": "int64", - "type": "integer" - }, - "video": { - "type": "string" - } - }, - "required": [ - "size_bytes" - ], - "type": "object" - }, - "FilmographyPage": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/FilmographyPage.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "completeness": { - "description": "Seerr filters adult credits and excludes crew here; not an exhaustive career", - "enum": [ - "returned-non-adult-cast" - ], - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/FilmographyTitle" - }, - "type": [ - "array", - "null" - ] - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "page": { - "format": "int64", - "type": "integer" - }, - "page_size": { - "format": "int64", - "type": "integer" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - }, - "source_paging": { - "enum": [ - "unpaged" - ], - "type": "string" - }, - "total_pages": { - "description": "Native pages of grouped typed titles; absent when source is unanswered", - "format": "int64", - "type": "integer" - }, - "total_results": { - "description": "Unique typed titles in this returned source set; absent when source is unanswered", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "performer_id", - "metadata_state", - "source", - "source_paging", - "completeness", - "page", - "page_size", - "items", - "observations" - ], - "type": "object" - }, - "FilmographyTitle": { - "additionalProperties": false, - "properties": { - "credits": { - "items": { - "$ref": "#/components/schemas/ActingCredit" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "$ref": "#/components/schemas/Title" - }, - "users": { - "description": "Configured viewers when visibility_state is configured; otherwise unknown", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "visibility_state": { - "description": "Configured library audience only, never observed Jellyfin visibility or playback", - "enum": [ - "configured", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "title", - "credits", - "visibility_state", - "users" - ], - "type": "object" - }, - "Genre": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "HealthDetail": { - "additionalProperties": false, - "properties": { - "failed": { - "type": "boolean" - }, - "has_file": { - "type": "boolean" - }, - "in_queue": { - "type": "boolean" - }, - "missing": { - "type": "boolean" - } - }, - "required": [ - "has_file", - "in_queue", - "failed", - "missing" - ], - "type": "object" - }, - "HeldTitle": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/HeldTitle.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "detail": { - "$ref": "#/components/schemas/ItemDetail" - }, - "episodes": { - "$ref": "#/components/schemas/EpisodeView" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial" - ], - "type": "string" - }, - "versions": { - "items": { - "$ref": "#/components/schemas/TitleVersion" - }, - "type": [ - "array", - "null" - ] - }, - "watched": { - "additionalProperties": { - "type": "boolean" - }, - "type": "object" - } - }, - "required": [ - "state", - "detail", - "libraries", - "versions", - "observations", - "watched" - ], - "type": "object" - }, - "ItemDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ItemDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "backdrop_ref": { - "description": "Public provider landscape artwork from the holding Arr", - "type": "string" - }, - "file": { - "$ref": "#/components/schemas/FileDetail" - }, - "health": { - "$ref": "#/components/schemas/HealthDetail" - }, - "is_anime": { - "type": "boolean" - }, - "media_type": { - "type": "string" - }, - "owners": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "poster_ref": { - "description": "Public provider poster artwork from the holding Arr", - "type": "string" - }, - "runtime": { - "description": "minutes", - "format": "int64", - "type": "integer" - }, - "source": { - "$ref": "#/components/schemas/SourceDetail" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "users": { - "description": "jellyfin usernames whose sees: matrix covers this item (movie watch-chip columns)", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "owners", - "is_anime", - "source", - "health", - "users" - ], - "type": "object" - }, - "LibraryAudience": { - "additionalProperties": false, - "properties": { - "is_anime": { - "type": "boolean" - }, - "libraries": { - "description": "Enabled configured household libraries covering this membership scope", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "owner": { - "description": "Named household membership used by the existing add, move and remove interface", - "type": "string" - }, - "users": { - "description": "Enabled Jellyfin usernames whose configured sees covers this scope, not watch-write authority", - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "owner", - "media_type", - "is_anime", - "libraries", - "users" - ], - "type": "object" - }, - "LibraryAudiencesBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/LibraryAudiencesBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "audiences": { - "items": { - "$ref": "#/components/schemas/LibraryAudience" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "audiences" - ], - "type": "object" - }, - "LibraryObservation": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "observed_at": { - "format": "date-time", - "type": "string" - }, - "source": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "source", - "state" - ], - "type": "object" - }, - "OpenLink": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "state": { - "enum": [ - "observed", - "missing", - "unavailable", - "unconfigured", - "not_permitted" - ], - "type": "string" - }, - "url": { - "description": "The viewer's own Jellyfin item page; the person signs in to Jellyfin as themself", - "type": "string" - } - }, - "required": [ - "state" - ], - "type": "object" - }, - "Operator": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Operator.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "capabilities": { - "$ref": "#/components/schemas/OperatorCapabilities" - }, - "name": { - "type": "string" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - }, - "subject": { - "type": "string" - }, - "watch_user": { - "type": "string" - } - }, - "required": [ - "subject", - "name", - "role", - "requester", - "watch_user", - "recommendation_profile", - "capabilities" - ], - "type": "object" - }, - "OperatorCapabilities": { - "additionalProperties": false, - "properties": { - "administer": { - "type": "boolean" - }, - "manage_library": { - "type": "boolean" - }, - "manage_memberships": { - "type": "boolean" - }, - "recommendation_profiles": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "request_media": { - "type": "boolean" - }, - "requesters": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "watch_users": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "administer", - "manage_library", - "manage_memberships", - "request_media", - "watch_users", - "recommendation_profiles", - "requesters" - ], - "type": "object" - }, - "Option": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "logo_url": { - "description": "Validated TMDB CDN logo for a watch provider; absent for genres and when upstream has no safe logo", - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "Options": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Options.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "genres": { - "items": { - "$ref": "#/components/schemas/Option" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "region": { - "description": "Comma-separated configured catalogue regions", - "type": "string" - }, - "services": { - "description": "Curated streaming providers observed in their configured regions, not title availability or playback evidence", - "items": { - "$ref": "#/components/schemas/Option" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "partial", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state", - "media_type", - "region", - "genres", - "services", - "observations" - ], - "type": "object" - }, - "Page": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Page.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "discovery": { - "$ref": "#/components/schemas/Discovery", - "description": "General discovery context and upstream page observation, independent of local holding freshness" - }, - "items": { - "items": { - "$ref": "#/components/schemas/Title" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "page": { - "format": "int64", - "type": "integer" - }, - "query": { - "type": "string" - }, - "source": { - "enum": [ - "search", - "general", - "related" - ], - "type": "string" - }, - "state": { - "enum": [ - "observed", - "partial" - ], - "type": "string" - }, - "total_pages": { - "description": "Known pages; merged regional service lanes expose only the materialized prefix until exhaustion", - "format": "int64", - "type": "integer" - }, - "total_results": { - "description": "Upstream matches before type filtering, or the known deduplicated prefix for merged regional service lanes", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "state", - "source", - "query", - "media_type", - "page", - "total_pages", - "total_results", - "items", - "observations" - ], - "type": "object" - }, - "PerformerDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/PerformerDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "also_known_as": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "biography": { - "type": "string" - }, - "birthday": { - "type": "string" - }, - "deathday": { - "type": "string" - }, - "known_for_department": { - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "name": { - "type": "string" - }, - "performer_id": { - "format": "int64", - "type": "integer" - }, - "place_of_birth": { - "type": "string" - }, - "profile_url": { - "type": "string" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - } - }, - "required": [ - "performer_id", - "metadata_state", - "source" - ], - "type": "object" - }, - "Person": { - "additionalProperties": false, - "properties": { - "key": { - "type": "string" - }, - "name": { - "description": "Jellyfin username the household member is known by", - "type": "string" - } - }, - "required": [ - "key", - "name" - ], - "type": "object" - }, - "Provider": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "logo_url": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name" - ], - "type": "object" - }, - "Rating": { - "additionalProperties": false, - "properties": { - "score": { - "description": "RT is 0\u2013100; IMDb and TMDB are 0\u201310. Absent is never zero.", - "format": "double", - "type": "number" - }, - "source": { - "enum": [ - "rt_critics", - "rt_audience", - "imdb", - "tmdb" - ], - "type": "string" - }, - "state": { - "enum": [ - "observed", - "unrated", - "missing", - "unavailable" - ], - "type": "string" - }, - "status": { - "description": "Observed RT critics or audience label, never inferred from a percentage.", - "type": "string" - }, - "url": { - "type": "string" - }, - "votes": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "source", - "state" - ], - "type": "object" - }, - "ReadProfile": { - "additionalProperties": false, - "properties": { - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "key", - "name" - ], - "type": "object" - }, - "ReadinessStage": { - "additionalProperties": false, - "properties": { - "name": { - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "observed_at": { - "description": "When the evidence was last observed (RFC 3339 UTC), when the owner records it", - "type": "string" - }, - "source": { - "description": "The evidence owner this stage reads; it never stands in for another stage", - "type": "string" - }, - "state": { - "enum": [ - "done", - "partial", - "in_progress", - "none", - "unknown", - "stale", - "conflicting", - "failed" - ], - "type": "string" - }, - "summary": { - "description": "What this stage's own evidence says, including why it is unknown, stale, conflicting or failed", - "type": "string" - } - }, - "required": [ - "name", - "state", - "summary", - "source" - ], - "type": "object" - }, - "RecommendationCoverage": { - "additionalProperties": false, - "properties": { - "complete": { - "description": "Successful ranked snapshot and all active predicates observed for its scoped candidates; not freshness, catalogue completeness or playback proof", - "type": "boolean" - }, - "excluded": { - "format": "int64", - "type": "integer" - }, - "matched": { - "format": "int64", - "type": "integer" - }, - "total": { - "format": "int64", - "type": "integer" - }, - "unresolved": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "total", - "matched", - "excluded", - "unresolved", - "complete" - ], - "type": "object" - }, - "RecommendationDismissal": { - "additionalProperties": false, - "properties": { - "hidden_at": { - "format": "date-time", - "type": "string" - }, - "hidden_by": { - "description": "Household requester key of the operator who hid it", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "poster_path": { - "type": "string" - }, - "title": { - "description": "Display name captured when hidden; empty when it was unknown", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "year": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id", - "title", - "year", - "poster_path", - "hidden_by", - "hidden_at" - ], - "type": "object" - }, - "RecommendationDismissals": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationDismissals.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "dismissals": { - "items": { - "$ref": "#/components/schemas/RecommendationDismissal" - }, - "type": [ - "array", - "null" - ] - }, - "profile": { - "type": "string" - } - }, - "required": [ - "profile", - "dismissals" - ], - "type": "object" - }, - "RecommendationFact": { - "additionalProperties": false, - "properties": { - "genre_source": { - "description": "Ranked genre context takes precedence; metadata is a fallback only when ranked genres are unknown", - "enum": [ - "ranked", - "metadata", - "unknown" - ], - "type": "string" - }, - "match": { - "enum": [ - "matched", - "excluded", - "unresolved" - ], - "type": "string" - }, - "metadata": { - "$ref": "#/components/schemas/TitleMetadata" - } - }, - "required": [ - "metadata", - "match", - "genre_source" - ], - "type": "object" - }, - "RecommendationFeed": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationFeed.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "attempted_at": { - "format": "date-time", - "type": "string" - }, - "expires_at": { - "description": "observed_at plus the configured feed interval; expiry only, not a scheduled refresh or retry", - "format": "date-time", - "type": "string" - }, - "failure": { - "enum": [ - "profile_resolution", - "memberships", - "history", - "candidates", - "cycle" - ], - "type": "string" - }, - "materialization": { - "enum": [ - "unknown", - "pending", - "current", - "failed" - ], - "type": "string" - }, - "movies": { - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - }, - "observed_at": { - "format": "date-time", - "type": "string" - }, - "outcome": { - "enum": [ - "unknown", - "ranked", - "no_seeds", - "empty" - ], - "type": "string" - }, - "profile": { - "$ref": "#/components/schemas/ReadProfile" - }, - "projection": { - "$ref": "#/components/schemas/RecommendationProjection" - }, - "shows": { - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "current", - "stale", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "projection", - "profile", - "state", - "outcome", - "materialization", - "movies", - "shows" - ], - "type": "object" - }, - "RecommendationHideInputBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationHideInputBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "poster_path": { - "maxLength": 200, - "type": "string" - }, - "title": { - "description": "Display hint, used only when the retained feed no longer carries the title", - "maxLength": 300, - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "minimum": 1, - "type": "integer" - }, - "year": { - "format": "int64", - "maximum": 9999, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "RecommendationHideResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationHideResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "created": { - "description": "False when the title was already hidden", - "type": "boolean" - }, - "dismissal": { - "$ref": "#/components/schemas/RecommendationDismissal" - }, - "materialization": { - "description": "Jellyfin recommendation folders after the hide; failed leaves the dismissal in force until the next cycle converges them", - "enum": [ - "current", - "failed", - "unchanged" - ], - "type": "string" - }, - "profile": { - "type": "string" - } - }, - "required": [ - "profile", - "dismissal", - "created", - "materialization" - ], - "type": "object" - }, - "RecommendationProfiles": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationProfiles.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "default_profile": { - "type": "string" - }, - "profiles": { - "items": { - "$ref": "#/components/schemas/ReadProfile" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "profiles", - "default_profile" - ], - "type": "object" - }, - "RecommendationProjection": { - "additionalProperties": false, - "properties": { - "coverage": { - "$ref": "#/components/schemas/RecommendationCoverage" - }, - "facts": { - "description": "Scoped candidates in original movie rank followed by original series rank; unknown observations never prove exclusion", - "items": { - "$ref": "#/components/schemas/RecommendationFact" - }, - "type": [ - "array", - "null" - ] - }, - "genre_id": { - "format": "int64", - "type": "integer" - }, - "media_type": { - "enum": [ - "all", - "movie", - "tv" - ], - "type": "string" - }, - "service_id": { - "format": "int64", - "type": "integer" - }, - "unresolved": { - "description": "Possible matches with unknown active predicates, in original per-media relative order", - "items": { - "$ref": "#/components/schemas/Candidate" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "media_type", - "genre_id", - "service_id", - "coverage", - "facts", - "unresolved" - ], - "type": "object" - }, - "RecommendationRestoreResult": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RecommendationRestoreResult.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "profile": { - "type": "string" - }, - "restored": { - "description": "False when the title was not hidden", - "type": "boolean" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "profile", - "media_type", - "tmdb_id", - "restored" - ], - "type": "object" - }, - "RequestIntentBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RequestIntentBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "description": "Household requester key to attribute the request to; defaults to the signed-in operator's own requester and must be one of the operator's capability requesters", - "type": "string" - }, - "seasons": { - "description": "Television only: the explicit seasons wanted (0 is specials). An empty selection is refused, never read as all seasons. Omit for a movie", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "default": "title", - "description": "Recommendation requires recommendation_profile and settles that profile's own acceptance only when it is also the requester", - "enum": [ - "title", - "recommendation" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "RequestProgress": { - "additionalProperties": false, - "properties": { - "actor": { - "$ref": "#/components/schemas/Actor", - "description": "Who filed it, as recorded; absent when the request was filed outside the cockpit" - }, - "awaiting_approval": { - "description": "The request is filed in Seerr and nothing has been dispatched, imported or observed yet, so no acquisition has started. Absent otherwise", - "type": "boolean" - }, - "blocking_stage": { - "description": "First stage without done evidence. Absent only when every stage has it. Not a readiness claim", - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "filed_by_you": { - "description": "The signed-in operator filed this command", - "type": "boolean" - }, - "id": { - "description": "request-N for a cockpit command, dispatch-N for a request Seerr approved outside the cockpit", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "on_behalf": { - "description": "The actor's own requester differs from the attributed requester", - "type": "boolean" - }, - "requested_at": { - "type": "string" - }, - "requester": { - "$ref": "#/components/schemas/Person", - "description": "The household member the request is attributed to; this decides which feed the item belongs to" - }, - "seasons": { - "description": "Television seasons explicitly requested; empty for a movie or when not recorded", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "stages": { - "items": { - "$ref": "#/components/schemas/ReadinessStage" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "description": "Known from the dispatch log or library membership; empty when no local record names the title yet", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "id", - "tmdb_id", - "media_type", - "requester", - "on_behalf", - "filed_by_you", - "seasons", - "stages" - ], - "type": "object" - }, - "RequestReceipt": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/RequestReceipt.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "actor": { - "$ref": "#/components/schemas/RequestReceiptActor", - "description": "Authenticated operator who issued the command; separate from the requester" - }, - "attempts": { - "format": "int64", - "type": "integer" - }, - "coverage": { - "description": "One outcome per selected season, never inferred from other seasons", - "items": { - "$ref": "#/components/schemas/RequestReceiptCoverage" - }, - "type": [ - "array", - "null" - ] - }, - "created_at": { - "type": "string" - }, - "detail": { - "description": "Why the command is pending or what was refused", - "type": "string" - }, - "id": { - "description": "Native request record; retrying the same command resumes this record", - "format": "int64", - "type": "integer" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "membership": { - "$ref": "#/components/schemas/RequestReceiptMembership" - }, - "on_behalf": { - "description": "The requester differs from the acting operator's own household requester", - "type": "boolean" - }, - "recommendation_profile": { - "type": "string" - }, - "requester": { - "description": "Household requester the request is attributed to", - "type": "string" - }, - "requester_username": { - "type": "string" - }, - "seasons": { - "description": "Explicit television seasons the operator asked for; empty for a movie", - "items": { - "format": "int64", - "type": "integer" - }, - "type": [ - "array", - "null" - ] - }, - "seerr": { - "$ref": "#/components/schemas/RequestReceiptSeerr" - }, - "source": { - "enum": [ - "title", - "recommendation" - ], - "type": "string" - }, - "state": { - "description": "Settled: every part has a verified outcome. Pending: a fact is unresolved. Retired: an administrator ended a stale-identity pending command without cancelling any Seerr request or reversing media effects", - "enum": [ - "settled", - "pending", - "retired" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "updated_at": { - "type": "string" - } - }, - "required": [ - "id", - "media_type", - "tmdb_id", - "state", - "requester", - "requester_username", - "actor", - "on_behalf", - "source", - "seasons", - "membership", - "seerr", - "coverage", - "attempts", - "created_at", - "updated_at" - ], - "type": "object" - }, - "RequestReceiptActor": { - "additionalProperties": false, - "properties": { - "name": { - "type": "string" - }, - "requester": { - "description": "The acting operator's own household requester key", - "type": "string" - }, - "role": { - "enum": [ - "admin", - "household" - ], - "type": "string" - } - }, - "required": [ - "name", - "role", - "requester" - ], - "type": "object" - }, - "RequestReceiptCoverage": { - "additionalProperties": false, - "properties": { - "outcome": { - "enum": [ - "requested", - "already_requested", - "already_held", - "unresolved" - ], - "type": "string" - }, - "request_id": { - "format": "int64", - "type": "integer" - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "outcome" - ], - "type": "object" - }, - "RequestReceiptMembership": { - "additionalProperties": false, - "properties": { - "bucket": { - "description": "Library route the membership landed in", - "type": "string" - }, - "dispatch_log_id": { - "format": "int64", - "type": "integer" - }, - "status": { - "enum": [ - "added", - "already_owned", - "pending_dispatch", - "conflict", - "failed" - ], - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "RequestReceiptSeerr": { - "additionalProperties": false, - "properties": { - "outcome": { - "enum": [ - "requested", - "already_requested", - "not_needed", - "not_attempted", - "unresolved" - ], - "type": "string" - }, - "request_id": { - "description": "Seerr request attributed to the requester that covers this command", - "format": "int64", - "type": "integer" - } - }, - "required": [ - "outcome" - ], - "type": "object" - }, - "Season": { - "additionalProperties": false, - "properties": { - "air_date": { - "type": "string" - }, - "coverage": { - "enum": [ - "none", - "partial", - "complete", - "unknown" - ], - "type": "string" - }, - "episode_count": { - "format": "int64", - "type": "integer" - }, - "name": { - "type": "string" - }, - "number": { - "format": "int64", - "type": "integer" - }, - "overview": { - "type": "string" - }, - "recorded_episodes": { - "format": "int64", - "type": "integer" - }, - "request_state": { - "enum": [ - "none", - "requested", - "unknown" - ], - "type": "string" - }, - "seerr_state": { - "enum": [ - "untracked", - "unknown", - "pending", - "processing", - "partial", - "available", - "blocklisted", - "deleted" - ], - "type": "string" - } - }, - "required": [ - "number", - "name", - "overview", - "episode_count", - "recorded_episodes", - "coverage", - "request_state", - "seerr_state" - ], - "type": "object" - }, - "SeasonCoverage": { - "additionalProperties": false, - "properties": { - "coverage": { - "enum": [ - "complete", - "partial", - "none", - "unknown" - ], - "type": "string" - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "coverage" - ], - "type": "object" - }, - "SeasonDetail": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/SeasonDetail.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "episodes": { - "items": { - "$ref": "#/components/schemas/Episode" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "observations": { - "items": { - "$ref": "#/components/schemas/LibraryObservation" - }, - "type": [ - "array", - "null" - ] - }, - "season": { - "$ref": "#/components/schemas/Season" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state", - "season", - "episodes", - "observations" - ], - "type": "object" - }, - "SeasonGroup": { - "additionalProperties": false, - "properties": { - "episodes": { - "items": { - "$ref": "#/components/schemas/EpisodeRow" - }, - "type": [ - "array", - "null" - ] - }, - "season": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "season", - "episodes" - ], - "type": "object" - }, - "SeedContribution": { - "additionalProperties": false, - "properties": { - "added": { - "description": "A successful attributed addition contributed nonzero seed weight", - "type": "boolean" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "title": { - "description": "Observed display name; absent when unknown", - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "watched": { - "description": "A played history event contributed nonzero seed weight", - "type": "boolean" - } - }, - "required": [ - "watched", - "added", - "media_type", - "tmdb_id" - ], - "type": "object" - }, - "SeerrObservation": { - "additionalProperties": false, - "properties": { - "detail": { - "type": "string" - }, - "requests": { - "items": { - "$ref": "#/components/schemas/SeerrRequest" - }, - "type": [ - "array", - "null" - ] - }, - "state": { - "enum": [ - "observed", - "unavailable" - ], - "type": "string" - } - }, - "required": [ - "state", - "requests" - ], - "type": "object" - }, - "SeerrRequest": { - "additionalProperties": false, - "properties": { - "id": { - "format": "int64", - "type": "integer" - }, - "status": { - "enum": [ - "pending", - "approved", - "declined", - "failed", - "completed", - "unknown" - ], - "type": "string" - } - }, - "required": [ - "id", - "status" - ], - "type": "object" - }, - "SourceDetail": { - "additionalProperties": false, - "properties": { - "arr": { - "type": "string" - }, - "imported": { - "description": "import timestamp (RFC3339)", - "type": "string" - }, - "indexer": { - "type": "string" - } - }, - "required": [ - "arr" - ], - "type": "object" - }, - "Title": { - "additionalProperties": false, - "properties": { - "backdrop_url": { - "description": "Landscape artwork on the TMDB image CDN; absent when none is observed or the upstream path is not a CDN-relative path", - "type": "string" - }, - "genre_ids": { - "description": "TMDB genre IDs in this title's media-type namespace; absent is unobserved, empty is observed without genres", - "items": { - "format": "int64", - "type": "integer" - }, - "type": "array" - }, - "held": { - "enum": [ - "held", - "unheld", - "unknown" - ], - "type": "string" - }, - "libraries": { - "items": { - "type": "string" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "overview": { - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "release_date": { - "type": "string" - }, - "request_state": { - "enum": [ - "none", - "requested", - "unknown" - ], - "type": "string" - }, - "seerr_state": { - "enum": [ - "untracked", - "unknown", - "pending", - "processing", - "partial", - "available", - "blocklisted", - "deleted" - ], - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "title", - "overview", - "held", - "request_state", - "seerr_state", - "libraries" - ], - "type": "object" - }, - "TitleCast": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleCast.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "credit_context": { - "description": "Seerr exposes only each TV performer's most frequent aggregate role, not every role or episode count", - "enum": [ - "movie-character", - "tv-leading-character" - ], - "type": "string" - }, - "items": { - "items": { - "$ref": "#/components/schemas/CastMember" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "paging": { - "description": "One complete returned Seerr cast list, not invented upstream pages", - "enum": [ - "unpaged" - ], - "type": "string" - }, - "source": { - "enum": [ - "seerr" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state", - "source", - "paging", - "credit_context", - "items" - ], - "type": "object" - }, - "TitleMetadata": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleMetadata.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "checked_at": { - "format": "date-time", - "type": "string" - }, - "genre_state": { - "enum": [ - "unobserved", - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "genres": { - "items": { - "$ref": "#/components/schemas/Genre" - }, - "type": [ - "array", - "null" - ] - }, - "genres_observed_at": { - "format": "date-time", - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "provider_state": { - "enum": [ - "unobserved", - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "providers": { - "items": { - "$ref": "#/components/schemas/Provider" - }, - "type": [ - "array", - "null" - ] - }, - "providers_observed_at": { - "format": "date-time", - "type": "string" - }, - "region": { - "enum": [ - "NL" - ], - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "region", - "genre_state", - "provider_state", - "genres", - "providers" - ], - "type": "object" - }, - "TitleRequestsBody": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRequestsBody.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "requests": { - "items": { - "$ref": "#/components/schemas/RequestReceipt" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "requests" - ], - "type": "object" - }, - "TitleVersion": { - "additionalProperties": false, - "properties": { - "file": { - "$ref": "#/components/schemas/FileDetail" - }, - "role": { - "enum": [ - "primary", - "mirror" - ], - "type": "string" - }, - "seasons": { - "items": { - "$ref": "#/components/schemas/SeasonGroup" - }, - "type": [ - "array", - "null" - ] - }, - "source": { - "type": "string" - } - }, - "required": [ - "source", - "role" - ], - "type": "object" - }, - "ViewerReadiness": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/ViewerReadiness.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "blocking_stage": { - "enum": [ - "request", - "acquisition", - "visibility", - "playback" - ], - "type": "string" - }, - "coverage": { - "description": "Recorded-file coverage of the requested seasons (or every listed season when none were recorded); empty for a movie", - "items": { - "$ref": "#/components/schemas/SeasonCoverage" - }, - "type": [ - "array", - "null" - ] - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "open_link": { - "$ref": "#/components/schemas/OpenLink" - }, - "seerr": { - "$ref": "#/components/schemas/SeerrObservation" - }, - "sources": { - "items": { - "$ref": "#/components/schemas/EvidenceSource" - }, - "type": [ - "array", - "null" - ] - }, - "stages": { - "items": { - "$ref": "#/components/schemas/ReadinessStage" - }, - "type": [ - "array", - "null" - ] - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - }, - "viewer": { - "$ref": "#/components/schemas/Person" - } - }, - "required": [ - "tmdb_id", - "media_type", - "viewer", - "stages", - "seerr", - "coverage", - "open_link", - "sources" - ], - "type": "object" - }, - "Display": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/Display.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "media_type": { - "enum": [ - "movie", - "tv" - ], - "type": "string" - }, - "metadata_state": { - "enum": [ - "observed", - "missing", - "unavailable" - ], - "type": "string" - }, - "poster_url": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tmdb_id": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "tmdb_id", - "media_type", - "metadata_state" - ], - "type": "object" - }, - "TitleRatings": { - "additionalProperties": false, - "properties": { - "$schema": { - "description": "A URL to the JSON Schema for this object.", - "examples": [ - "https://example.com/schemas/TitleRatings.json" - ], - "format": "uri", - "readOnly": true, - "type": "string" - }, - "ratings": { - "items": { - "$ref": "#/components/schemas/Rating" - }, - "type": [ - "array", - "null" - ] - } - }, - "required": [ - "ratings" - ], - "type": "object" - } -} diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index ce7d039f..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM eclipse-temurin:21.0.11_10-jdk-jammy@sha256:dbfd085220ae632a0830166e443747d1ee89e9038d92e3b48c3e5e9d8292b9a7 - -RUN apt-get update && apt-get install -y --no-install-recommends git unzip \ - && rm -rf /var/lib/apt/lists/* -ENV ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk -ENV PATH="/opt/android-sdk/cmdline-tools/23.0/bin:/opt/android-sdk/build-tools/36.0.0:${PATH}" -RUN curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o /tmp/tools.zip \ - && echo 'e025545c62a8e64c7559119566a569fb1dec5f60 /tmp/tools.zip' | sha1sum -c - \ - && mkdir -p "$ANDROID_HOME/cmdline-tools" \ - && unzip -q /tmp/tools.zip -d /tmp/android-tools \ - && mv /tmp/android-tools/cmdline-tools "$ANDROID_HOME/cmdline-tools/23.0" \ - && rm -rf /tmp/tools.zip /tmp/android-tools \ - && yes | sdkmanager --licenses >/dev/null \ - && sdkmanager 'platforms;android-37.0' 'build-tools;36.0.0' \ - && while IFS='=' read -r name value; do \ - if [ "$name" = Pkg.Revision ]; then test "$value" = 2 || exit 1; fi; \ - done < "$ANDROID_HOME/platforms/android-37.0/source.properties" \ - && curl -fsSL https://dl.google.com/android/repository/platform-tools_r37.0.1-linux.zip -o /tmp/platform-tools.zip \ - && echo '477254aa5f903c15cf51001717bdf347fb6b53e0 /tmp/platform-tools.zip' | sha1sum -c - \ - && unzip -q /tmp/platform-tools.zip -d "$ANDROID_HOME" \ - && rm /tmp/platform-tools.zip \ - && mkdir -m 1777 /gradle -ENV GRADLE_USER_HOME=/gradle HOME=/tmp -COPY signing.init.gradle /opt/wolfskin/signing.init.gradle diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index e2e760c6..00000000 --- a/docker/README.md +++ /dev/null @@ -1,873 +0,0 @@ -# Wolfskin TV container builds - -Run from a full Git checkout with the upstream `v*` and `p*` tags available. The -host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the -Android SDK. - -```sh -git fetch origin --tags -make apk # clean, signed default release: three ABI splits + universal -make test # app default/debug suite plus wolfskin-design JVM/capture suite -make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders -``` - -APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds -are not playback-complete and must not be distributed as releases. Upstream's -`.github/workflows` and dependency version catalog are unchanged; release builds -use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not -place local extension AARs in `app/libs`: upstream prefers them over the pinned -GitHub Packages dependencies. - -Upload release assets renamed to `Wolfskin.apk` / `Wolfskin-<abi>.apk` for the -in-app updater. Gradle outputs -`Wholphin-default-release-<versionName>-<versionCode>[-<abi>].apk`; strip the -flavor, version, and code as well as replacing the prefix. - -Upstream enables JVM unit tests only for debug variants; there is no -`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor -task without changing upstream's test configuration. - -## Optimized side-by-side comparison - -The `optimized` build type inherits `release`: R8 optimization/minification, -resource shrinking, non-debuggable code, dependency consumer keep rules and -dependency baseline profiles. It changes only signing (the Android debug key), -package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`). -The launcher icon/banner and release-compatible version name are unchanged: -the shared upgrade/updater parser rejects extra suffixes such as `-opt`. -`make apk` remains the clean, release-key-signed production build; neither its -signing flow nor the existing debug build changes. - -Build all four default-flavor outputs in one invocation through the container -wrapper, without a release keystore. On a host whose Gradle cache already holds -the pinned native extensions and their metadata: - -```sh -bash docker/gradle.sh optimized --offline \ - -PWholphinExtensionsUsername=JustinZeus \ - -PWholphinExtensionsPassword=unused \ - :app:assembleDefaultOptimized -``` - -The unused password is not a credential: offline resolution never contacts -GitHub Packages. A cold cache instead needs a read-packages credential supplied -through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and -`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container, -or securely seeded cached artifacts. No release signing inputs are needed. -Without native extension configuration, the existing dependency selection uses -the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a -playback-complete comparison. Inspect native libraries before installing. - -Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a, -armeabi-v7a and x86_64. Keep -`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to -deobfuscate crashes. This variant shares release library variants and the release -`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork. -`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode -or Compose diagnostic stack traces. - -This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal -server/user flow again; it does not inherit the debug/release app's logins or -preferences. **Wolfskin screens** defaults on, and the same Advanced switch can -restore upstream screens. The same Conductarr door -(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected -Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential -or embedded secret is added. All other settings retain release behavior, -including update checks; a production release APK is not an update for this -separate package/debug signer. - -`androidx.profileinstaller` and library-provided profiles already exist. There -is no recorded app baseline-profile source or Macrobenchmark module. -`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and -activity classes plus their `onCreate` methods, `WolfskinAppKt`, -`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library -profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and -`baseline.profm`. It is not a measured navigation profile or evidence of a speedup. -To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark -module, record cold launch, Quick Connect/sign-in, person selection, Home, -Libraries, Discover, title pages and playback on a suitable device, then rebuild -and compare startup/frame timings. Device-driven recording and runtime checks -remain separate from an APK build. -The optimized source set explicitly registers that profile directory; a bare -`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration. - -Static verification: use `aapt2 dump badging` for package/label/SDKs and absence -of `application-debuggable`; `apksigner verify --print-certs` for the debug -signer; `unzip -l` for both dexopt assets, native ABI contents and absence of -`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective -consumer rules rather than adding blanket keeps. JVM tests do not prove -minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real -Conductarr reads, artwork, database reopen and each player on the comparison box. -`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug -variant has no JVM unit-test task, just like release. The existing debug JVM -`VersionCompareTests` receives the actual optimized version name from the variant -API and checks it with the same `Version.fromString` used by upgrade/update handling. -Build logic is otherwise checked through actual APK assembly and static inspection. - - -## Upstream v1.1.0 integration - -The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and -ExoplayerHdrUtils `0.4.1`. The pinned SDK/JDK image and serialized Gradle wrapper -remain compatible without changes; native Wholphin extensions remain `0.2.4`. -There are no committed dependency lockfiles or Gradle verification metadata. -Forgejo Actions stays disabled; upstream GitHub workflows remain dormant. - -The app database upgrades from Room schema 35 through 40 to 41 on first launch -after installation. The upstream migrations add series track choices and a home -settings source preference. Downgrading with the upgraded database is not -supported: a TV installation is a one-way step and requires explicit approval. -The optional companion plugin can supply Discover credentials before locally -saved Seerr profiles; do not configure an administrator API key in place of the -household's per-profile Jellyfin login, which Conductarr uses for request routing. - -Build only after committing a release merge: version names come from Git HEAD, -not the working tree. An uncommitted merge still builds with the old version name. -Neither an upstream merge nor its validation build authorizes a release or TV -installation. - -## Wolfskin shell - -Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in -entry in `MainContent.kt` calls `WolfskinApp`; Wolfskin-only routes use the single -`Destination.Wolfskin` subtype in the existing navigation stack. Other destinations -continue through upstream `DestinationContent`. Full-screen player/settings pages -cover the Wolfskin nav. - -The independent `:wolfskin-design` module owns the shell components, fixed person -colours, focus policy, per-person root-view memory, palette and motion. Its -`tokens.json` is the copied approved TV-spec source, retaining the locked prototype -SHA and provenance. `shell-tokens.json` adds shared shell/grid/sheet measurements; -`title-tokens.json`, `home-tokens.json` and `discover-tokens.json` add their screen -measurements from the same locked prototype. Generate Kotlin, CSS and the checksum manifest together: - -```sh -python3 wolfskin-design/generate_tokens.py -python3 wolfskin-design/generate_tokens.py --check -``` - -`preBuild` checks the manifest against the source JSON, generator and generated -outputs. Editing any of them without regenerating fails the build. The shell uses -the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old -numeric value is reserved. - -Compose output maps source weights 600/650 to the platform Bold (700) face, which -resolves consistently in native captures; the approved JSON weights stay unchanged. -Nav, picker names, labels and pill weights use that same mapping. Nav label colour -fades for 200 ms ease-out; the blob retains its critically damped spring. - -Shell view accessors are read-only, including the first read from Discover's -`snapshotFlow`. Construction and explicit person/navigation events retain root -and destination memory; reading an unknown destination returns an unsaved default. -Per-person focus, cards and scrollers still survive route changes, while reselecting -the current root resets it. JVM regressions exercise both read-only snapshots and -the signed-in asynchronous Discover host without a screen prewarming its memory. - -Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. -The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box -padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width -ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. - -The small gear under the final person opens Settings with Requests focused. -Settings groups hand off to existing upstream pages; Requests opens the household -Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent -rollout switch: turning it off renders upstream Home and library destinations with -the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and -Settings list. Native Up traversal stays in content until the first focusable row, -then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. -Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. - -Saved Jellyfin identities use the existing upstream session. Conductarr supplies -household labels, requester capabilities and library audiences after sign-in. -The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used -only when the Advanced debug fixture switch is enabled; release excludes it. - -Person selection immediately clears the old Conductarr identity and Jellyfin -read gate before saved-login I/O. Detached clients are disposed exactly once on -the application's IO scope, after the replacement (or signed-out) identity is -published; a slow or failing socket close cannot block the picker or abort a -switch. Cleanup outlives the replaced screen. Retry and session replacement use -the same disposal owner. Superseded sign-ins are cancelled and serialized so a -late reply cannot navigate back to an earlier person. - -The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its -server and shell state, not to the user-scoped ViewModel. Switching people replaces -the upstream app entry and ViewModel but retains the same-server, same-mode shell: -the picker stays closed on Home after the first OK. A cold start still opens it, -and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real -shell and user-entry replacement for launch/avatar Left and Right, the initial -person, gear and back, and fast Left-Left-OK; each selection emits one person action. - -Preference and people-fixture reads, Conductarr client construction (including -Home/title retries), and private-cache directory initialization run on IO. -`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel → -Shared → Justin while all closes are held pending, verifies retired clients -cannot start reads, contains a failing close, and checks a burst of choices -against a late sign-in reply. The thread-recording close fake rejects the main -Looper; actual TLS/StrictMode verification remains a real-device check. - -Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous -See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` -destination is an explicit title-family integration stub exposing only Discover's -request and pick actions. Unheld titles have no trailer control until the pinned -Conductarr title contract supplies a trailer. - -The debug-only fixture and local art live in -`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a -network-free fixture host; real providers are the default in debug and release. -The mutation adapter accepts the selected person's `ConductarrClient` and uses -only typed routes. Request submission first reads title receipts. Unknown outcomes -are checked, never blindly re-sent, including after closing a sheet or choosing -the same uncertain recipient again. Whole-series requests require season metadata. - -Discover hero and Restore text use explicit per-line CSS allocations so native -paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles -retain the sheet's negative top margin and bottom gap. Recovery panels may -overflow their fixed-height scroll row so sign-in copy never shrinks the retry -button. Native regressions cover those three layout boundaries. - -Discover's native capture suite covers its 28 named acceptance states and emits -text-layout metrics beside the PNGs. Data-driven Android key replays cover opening -Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and -Not interested in 8. Additional regressions exercise held-OK safety, uncertain -receipts, per-recipient outcomes, collection recovery and Undo, unknown seasons, -Dutch decimal formatting and short-row/tail clamping. Discover reuses the Libraries -grid, poster, ratings plate, See all and recovery cards, bounded image loader, caption -separator token and the shell's single hold guard. Both frames share the 180 ms scroll tween. - -The TV keeps platform Roboto with the approved type sizes and weights. Browser -system-font wrapping is not a per-title contract: no bundled font, forced title -breaks or width compensation is applied. Discover's own stacked text preserves -untrimmed CSS line boxes without changing merged shell/library typography. Hero, -sheet and shared focused-content shadows use tokenized drop shadows, not Material -elevation; their alpha animates in the draw phase. Native text-layout measurements -accompany each acceptance capture; remaining font-driven wrapping and text-block -positions must be listed explicitly in its comparison report. - -The asynchronous debug fixture seam hands loading focus back to the first loaded -action when Discover is still the selected destination. A JVM regression releases -the actual host's suspended fixture load and presses Android OK: it must open the -hero rather than activate the nav. Fixture completion does not steal a different -destination or nav selection. - -Native Roborazzi captures use Robolectric SDK 34 and -`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and -the sampled Compose nav-spring curve to `build/wolfskin-captures/`. -Home, Library and Discover captures use `home/`, `library/` and `discover/` -subdirectories so shared spec state numbers cannot overwrite another renderer's image. -Key replays exercise the Android event adapter, including the 6/8/10/11-press shell paths. -The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a -separate press-count path. - -Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent -dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers -use that local directory in offline mode instead of downloading Android JARs into -the container's ephemeral home. A cache miss still needs Maven Central; rate-limited -hosts can seed the existing cache from SHA512-verified local Maven artifacts without -changing the wrapper or adding volumes. - -Default-flavour launcher artwork and English, Hungarian and Polish string overrides -live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated -`main` resource folders have not yet been moved; non-default `appstore`/`firetv` -flavours therefore combine upstream icons with some legacy Wolfskin text. Those -flavours and a full locale migration are not certified by this slice. Debug -assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public -artifact provider and AAPT2. It checks all string, plural and array configurations, -rejecting user-visible upstream branding while preserving the upstream legal -attribution and project URL. The check does not depend on incremental merge files. - -## Wolfskin Home - -Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, -Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. -The final action opens the real merged Discover screen at its top through the -shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying -the complete underlying list rather than the visible shelf window. Title content -remains the later-slice target. - -All Home components and immutable screen models live in `:wolfskin-design`. -The shell's remembered view owns vertical position, each row's selected card and -actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. -Composing destinations bind `rememberedView(route)` before navigation effects run, so a -new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the -remembered track before the page with `scrollToItem`, preserving exact indices and -pixel offsets rather than animating back. Android focus stays with the shell; -cards use its logical focus, without requests against uncomposed lazy items. -Back preserves the viewport; returning to Home does not re-run focus-follow over -a manually scrolled saved view. The next arrow re-enters visible content when -scrolling moved focus off-screen. -Pointing changes focus without activation, cards activate on key-up, and held -OK events cannot activate twice. -Between shelves, an unvisited row selects the nearest visible card center even -when card widths differ. A visited row restores its column. Horizontal movement -keeps the track still while focus remains in its visible middle. -The initial skeleton parks focus on the nav. When the first content arrives, -an untouched fresh entry focuses hero button zero; intervening nav input prevents -that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` -release/repeat guard and Back ladder. - -`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release -variants read the selected user's Resume, Next up, newest titles and destination -shelves, with app-private last-known-good projections through the shared atomic -disk cache. A missing saved library becomes a recovery panel; a saved library -stays visible when Jellyfin is unreachable. Playback resolves an actual film or -episode and checks Jellyfin before opening upstream's player. No season, -recommendation stub or different person's session is promoted to playback. -An unresolved person switch guards Home and both owned/upstream library content -before mounting any source read with the prior person's Jellyfin session. - -Debug variants additionally package the locked fixture and local artwork under -`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. -The explicit fixture provider supplies approved household data and Home states -without network reads in tests. Real signed-out Home hides every rating and shows -"Discover needs sign-in"; signed-in Home reads the Conductarr continuation and -ratings-only endpoint. No Seerr fallback is used. - -Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; -those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters, -disable crossfades, share a 64 MiB memory/128 MiB disk cache, limit image work to -four reads and prefetch the next shelf. Rating plates start at 14 sp, shrink to 12 sp -and drop trailing entries rather than wrap. Their uncached read gate is 350 ms; -cached focus returns without that delay. - -Focus rings and native lift draw outside fixed slots. The Saved nav chip uses its -own geometry; the Discover section uses a warning pill. Caption separators multiply -the existing muted colour alpha by the shared 40% token. Home-only untrimmed styles -and `exactLineBox` honor the declared CSS line boxes despite Android's intrinsic -paragraph minimums; shell and library typography retain their existing defaults. -The shared See all component uses its canonical locked 8px gap, not the generic -10px tail spacing. Focus lifts use the merged draw-only `FocusLift` implementation; -the obsolete Home shadow helper and duplicate gap/lift measurements are removed. -Vertical moves clamp to short target rows, including a final See all card. - -`make test` includes `HomeCaptureTest` (23 native acceptance states) and the -data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover -4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses. -Home captures are written to `build/wolfskin-captures/home/`. Compare those images -side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation, -the approved button-scale consolidation and removal of prototype sample pills -are recorded differences, not a claim of browser pixel equality. - -## Requests under Settings - -The Requests row and Coming-in sheet share one immutable household snapshot. -Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, -Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four -with readiness expanded. Right expands Request/Download/Library/Playback; Left -collapses; Up/Down collapse while moving. Opening a title preserves the request -for Back, and closing the sheet restores its opener. Selecting a root or changing -person clears the title-return link. Shared's authenticated actor always reads -“by Shared”, never “by you”. - -The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and -its eight local posters. Shared and focused-rating records also feed the native -acceptance states; the identical projection is in design-test resources. -No “sample” or “example stages” pill is drawn. The default provider instead maps -Conductarr household activity, authenticated actors and display artwork into -the same sheet. Release excludes all fixture assets. There is no Seerr fallback. -Foreground refreshes once; an open sheet or unsettled requests refresh every -30 seconds while started. The immutable debug projection is loaded once, not -decoded again on every poll. -Thumbnails reuse `WolfskinImages` with a 96×144-pixel request, RGB565, no crossfade, -the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator -dots use the surrounding text colour; the 40% rule applies only to poster captions. - -Uncached focused ratings wait 350 ms; moving away cancels the read. Read ratings -show immediately on return; sign-out clears visibility. This follows the frozen -page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8. -Deterministic coroutine-clock tests cover cancellation, reentry, cached return, -sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch -rendering and sign-out suppression. The approved nine-key Requests path is JSON -data replayed through the real shell. The Requests-only harness starts at the -Home-to-nav boundary; Home's actual hero and asynchronous entry have separate key replays. - -Sheet dimensions, padding, glyphs, scales, colours and durations are generated -tokens. Scoped sheet line boxes retain CSS heights and baselines independently -of native leading rounding; shared shell/library typography is unchanged. Platform -Roboto remains required: the host browser used DejaVu Sans, so its width, -wrapping, ellipsis and text-block positions are not a wrapping-parity target. -For example, the 44-pixel “Coming in” ink is 239 pixels wide in the reference -and 194 pixels in native captures. No per-title compensation or font bundle is -used. Native comparisons must report those structural differences explicitly. -Readiness glyphs sit inline on the text baseline with no flex gap, matching the -frozen block-span cascade. Items wrap intrinsically: Roboto fits the expanded -readiness into two lines where the reference uses three; no grouping is forced. - -The six rating marks retain the exact locked SVG source in -`wolfskin-design/request-marks/`; native-size PNGs and their hashes are generated -from the JSON dimensions with the locked contain fit (`.rr .rb` outranks `.crow img`). -Inside Coming-in rows they retain the prototype's dark `surface3` chips with 12-pixel rounded corners. -Loaded poster thumbnails have no placeholder fill beneath their clipped edge. - -```sh -uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py -``` - -`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080, -density 2.0. Title selection opens the shared native title destination by media -type and TMDB identity. Back restores the exact Coming-in row and expansion; -the temporary `request-title/...` placeholder is removed. TV sign-in, release -and device deployment remain separate. - -## Libraries and See all - -Movies, Shows and Anime use the shell's retained view and a continuous six-column -lazy grid, reduced to five columns while the letter rail is visible so scaled -posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav; -Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating. -The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through -the same shell hold guard, without a long-press menu. -Down clamps a short final title row before entering the separate Try again / Show -more row; an empty slot cannot activate recovery. An unavailable library hides -its unknown item count. - -Caption separators use 40% of the existing muted colour's alpha. The rail follows -the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, -overriding the spec table's generic caption style for this component. -Library and Settings header initials use the muted colour over the person's fill; -navigation and picker avatars retain their dark initials. - -The debug-only `wolfskin-library.json` and `library/assets/` contain the locked -prototype's titles and local poster art. Release libraries read through -`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person -snapshots using the existing atomic disk cache. Saved data is painted before a -quiet refresh; a failed refresh keeps it. Production ratings remain hidden before -Conductarr sign-in and use the shared ratings-only provider afterwards. - -Home/Discover shelves open a full list through -`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`. -The optional suspend page provider returns `LibraryPage(items, hasMore)` using the -generated Conductarr client. The grid reads within ten cards of its end, deduplicates -title identities, and pauses on Try again or Show more rather than looping. -Title activation opens `title/{id}` through `Destination.Wolfskin` for prototype -identities, or the existing typed media destination for real Jellyfin identities. -Movies, series, episodes and people use the native title family; collections and -music keep their upstream destinations. - -`LibraryCaptureTest` renders the sixteen approved library/See all states offline. -`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly -20 Android key presses. Paging and snapshot tests cover duplicate-only reads, -failure/retry, stale copies, and person/server isolation. Library captures are written -to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. - -### Roomy card spacing - -The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf -heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080. -Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px, -preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px: -chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear). -Discover and season-chip rows retain their 104 px height. - -Cards and cast circles draw a single 4 px accent stroke centred on the art edge, -above the content, with no background gap. Card lift parameters are unchanged; -cast portraits and episode tiles now receive that lift too. The stroke and art -scale together at 1.08. -Title shelves reserve vertical drawing room for the scaled ring. -Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px -for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. - -Poster progress uses the independent 18 px title-card inset, not caption spacing: -the track remains 220 px wide on a 256 px poster. Library focus-follow restarts -when the effective column count, focused row or header geometry changes, retaining -deep focus across live snapshots that add or remove the letter rail. -`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged -focused index across the 199↔200-title boundary. - -`LibraryCaptureTest.spacingShowsFirstFocused`, -`HomeCaptureTest.spacingNextUpFirstFocused` and -`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states -at native resolution. Their files are `library/spacing-shows.png`, -`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. -These captures are recordings, not automated reference-pixel assertions; pair -measurements are a separate check. Platform Roboto metrics and fixture ordering -are not browser pixel parity. - -### Shared focus rings - -The same `focusRing` stroke now covers pill/icon buttons (hero, title, state, -recovery and sheet actions), the nav avatar, picker people and gear. It is always -accent, 4 px / 2 dp wide, centred on the surface edge with no background gap. -The former outside-gap renderer is removed. The stroke draws after opaque -content, so neither half can be covered by the background or portrait. - -Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`; -avatar and picker tokens use the same geometry. Existing scales remain 1.05 for -buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card -and button lifts remain; avatars, people and gear gain no shadow. Existing -clearance accommodates the smaller outside reach without layout movement. - -Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail -are unchanged. Selected-only chip/icon/radio borders and decorative borders -are not focus rings. These distinctions are explicit in the inventory and -reference states rather than silently adding rings to filled controls. - -`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the -two outside and two inside pixels against opaque content for pill, circle and -card radii, including clearing the stroke on blur. The focused hero Details, -nav avatar, picker, Discover chip, Library toolbar chip and title Request -captures exercise the actual screens at 1920×1080. Browser and native glyph -metrics differ; their ring offsets are measured relative to each surface edge. - -## Title pages and people - -`TitleSurface` owns the film/series/episode header, season chips, episode tiles, -cast and related shelves. `PersonSurface` owns the real biography and continuous -six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`, -the adaptive `LibraryRatingPlate`, shared captions and the single bounded -`WolfskinImages` loader. Image requests use exact slot sizes, RGB565 for opaque -posters, no crossfade, four concurrent reads and next-row prefetch. The fixed -title backdrop remains visible behind deep rows; only off-screen row images -leave composition. - -The options row preserves complete labels beyond the copy-column measure. -Version choices close after selection; Audio and subtitles stays open, including -an explicit Off choice. Real source IDs use the Jellyfin SDK UUID parser, and -stream indexes remain the server's indexes rather than positions in a list. -Selections persist through the same playback repository as the upstream player. -Marking a whole series asks first; an explicit episode remains the exact play and -watched target even while another season is selected. Nested person/title visits -retain their entry's focus and selection state. -Hardware and remote Back share the sheet/picker/request-overlay precedence, then -pop a pushed title/person from content **or** nav. Initial loading has no content -target: Down stays in nav, while Back can still leave the title. A missing rating -set emits no layout node, so it cannot reserve an invisible extra facts gap. - -`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, -person credits and playback mutations to one boundary. `ConductarrTitlePages` -adds real detail, cast, related titles, performer/filmography, readiness/receipts -and source ratings. A catalogue title gains held/play controls only after an -exact TMDB lookup returns this Jellyfin user's actual item identity. Episode -votes enrich those real episode identities; no catalogue stub becomes playable. -Debug-only `wolfskin-titles.json` and local artwork remain behind the explicit -Advanced fixture switch; release never packages or selects them. - -Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an -installed YouTube handler such as SmartTube, never a browser. No handler leaves -the title visible with a calm message. Titles nobody holds have no Trailer -button until Conductarr's trailer field exists. Title requests use the same -actor-safe chooser and identity-lifetime uncertain-submission ledger as Discover. -Signed-out and offline pages cannot submit; no Seerr fallback is used. - -`TitleCaptureTest` writes the 34 approved title/person/options frames plus native -text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved -typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title -compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title -headers, sheets, cast and poster captions without changing shared type tokens. -Fractional stack placement rounds only the accumulated position, preventing a -one-pixel-per-row drift. Focused controls share the canonical lift shadow. -`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row -entry, remembered columns, long-sheet visibility and full media-table geometry. -`TitleAsyncFocusTest` delays title and person replies through the real destination -and view-model, then exercises content focus and hardware Back. Subtitles Off -maps to the player's explicit disabled sentinel, not its unspecified default. - -`TitleLaunchKeysTest` starts at Who's watching and replays the full 4/8/15/11 -sequences through the real Home hero/shelves and title/person surfaces. -`title-press-paths.json` supplies every event; no prefix is skipped or padded. -The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache, -exact-episode, track-index, identity, locale-layout and trailer-handler tests -run offline with in-process service replies and local art. - -## Offline states and recovery - -Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width, -22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes. -Network failures never expose transport exception text. Playback failure reads -“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the -originating card focused; no simulated player hand-off panel is shipped. Retry -failure reads “Still can’t reach it. The last saved view stays on screen.” - -Library headers and the nav publish the same saved-copy timestamp. Failed library -refreshes retain cards and expose a reachable Try again tail. Discover retains its -current catalogue after a failed refresh, adds a recovery tail and disables request -and dismissal writes until recovery; a revoked sign-in discards that saved projection. -Previously observed plates can remain visible offline, but moving to an uncached -card cannot start a ratings read. `SavedPill` shares the compact cached-header -treatment across Home, Libraries and Requests; the nav retains its larger wifi chip. - -Requests focus, observed ratings and retained rows are partitioned by selected person. -A failed authenticated read preserves the last rows and their timestamp; Down from -the final row reaches Try again and Up returns to the row. Signed-out data is not -retained as an authenticated copy. Returning to a foreground person triggers that -person's refresh rather than waiting for the prior person's polling timer. - -Home disk snapshots remain server/user scoped. A new identity starts with its own -skeleton or saved copy, never the previous person's projection; late responses from -an older read cannot overwrite the current identity. A disk-write failure does not -turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and -Discover screens keep focus in the nav even when Down is pressed repeatedly. - -Library/Discover posters use the same title/glyph/160-degree gradient fallback as -Home, including an actual Coil read failure. Discover hero image failures use the -shared ambient background; missing artwork is not a successful data refresh. - -`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest` -exercise state transitions, person isolation, native recovery focus/geometry and the -real upstream playback destination with offline fixtures. New native captures and -rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score -captures configure the Android resource locale as well as the JVM locale. - -Conductarr transport and identity live outside the state components. A revoked -sign-in discards the current person's authenticated projections; an outage keeps -last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline -request queue is used. Title and performer pages use the same authenticated cache. - -## Conductarr production wiring - -The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`: -`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside -**Wolfskin screens** in Advanced settings; blank restores the build default. -HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures** -is a debug-only, off-by-default switch; it never sends HTTP mutations. - -`ConductarrSession` borrows the currently selected user's existing Jellyfin access -token. It sends only `Authorization: MediaBrowser Token="<token>"`, with no cookie, -API key or second credential store. Person/server/token/endpoint changes discard -the old client and in-memory providers. LKG files are isolated by person and client -identity in app-private, backup-excluded storage. A 401 clears that identity's -cache and returns to sign-in states; Try again reconstructs and verifies the client. - -Home publishes Jellyfin/LKG rows independently of Conductarr, including retry -authentication, and paints new Jellyfin data before writing its disk copy. -Production calls use 3-second connect, 5-second read and 8-second total limits. -Request recipients include authorized Conductarr profiles without saved Jellyfin -logins; that does not create selectable TV logins. Late name reads preserve the -open chooser's selected recipient and footer focus. - -The launch picker publishes the current Jellyfin user and working navigation before -the saved-login database read. Saved people and Conductarr household metadata fill -that same shell later; slow activity or catalogue reads cannot hold its first usable -frame. Discover details navigate to the shared title page, including cast, performer -navigation and related titles, rather than the header-only design preview. Held -items whose detail omits ProviderIds or People get an explicit Jellyfin field read -before catalogue enrichment, without changing their playback identity. Scrolled -navigation draws an opaque band; the unscrolled hero treatment is unchanged. -Cached `NavDisplay` entries read the current shell and Discover composition locals, -not the temporary launch shell captured when the entry was first created. Otherwise -the screen can retain skeleton people, disconnected tab actions and an open picker -even after the real session is ready. -Only the current navigation entry may set the shared route or enable the outer -host's Back handler; outgoing entries can remain composed during a transition. -Nested title handlers retain their existing lifecycle-based behaviour. - -Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's -authenticated fetcher. The app's network interceptor removes Authorization from -every non-Jellyfin artwork origin (scheme, host and port), including redirects; -current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps -the existing mapping or Jellyfin name rule. Returning to the foreground retries -household metadata without rebuilding the selected identity. -An Anime audience grants a library grouping, not proof of owned titles. Household -refresh enables Anime only after a one-title Jellyfin read confirms visible series -in an accessible Anime library for the signed-in user. This runs after the live -picker is published; failures retain that user's last confirmed availability. -Empty Anime Home shelves remain omitted. -When availability changes, focus follows the avatar or a surviving destination; -removing Anime never leaves an out-of-range nav selection. If Anime was the root, -both the shell and navigation stack replace it with Home while retaining pushed -title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus, -and the real navigation stack with one or two pushed pages. - -| Screen seam | Production supplier | -| --- | --- | -| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | -| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only | -| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes | -| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters | -| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token | -| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh | -| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes | -| Titles and people | Detail/enrichment/display, cast, related, real Jellyfin season episodes plus votes, performer/filmography, title activity and uncached receipts; shared request/hide workflows | - -The pinned client exposes exactly the 22 TV operations, including ratings and -display. Conditional reads send ETags and reuse the saved body on 304. The fake -server tests use representative wire JSON, never a live Conductarr or Jellyfin. -`ConductarrSessionTest`, `ConductarrProjectionTest`, `ConductarrTitlePagesTest`, -`ConductarrSurfaceTest`, `ConductarrHomeSurfaceTest` and `ConductarrTitleSurfaceTest` cover identity/cache -isolation, 401/retry, saved offline data, actor-safe requests, ratings timing, -title playback permissions/episode votes and native real-provider surfaces. -The existing fixture screen tests remain in the normal `make test` gate. -`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts -and a same-host/different-port origin with an authenticated upstream parent, -and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers -operator success plus failed/empty audiences, name-rule retention and foreground healing. - -`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending -while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest` -covers omitted held-film provider ids with authenticated cast/related enrichment. -`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back -and writes native cast/related captures to `walk-fixes/`. -`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band. -`FirstSignInNavigationTest` starts the full app host with no existing shell and -immediate signed-in replies, then continues as Mindel and renders her six-film -Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also -checks that Continue closes the real session picker and selecting Home does not -reopen it. -`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared -and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or -Home shelf, while a real visible series enables both. - -Not done here: deploying the dedicated hostname or widening server authority; -hardware installation/playback verification; releases, signing or version changes. -Unheld trailers remain absent because the server contract has no trailer field. -Saved Jellyfin logins are not fabricated from Conductarr profiles: a person -without an upstream token remains signed out. Fixtures require explicit debug opt-in. - -### Manual smoke after endpoint deployment (no build command) - -Use an already authorized test build and a sandbox that accepts its selected -Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin. - -1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared, - then another saved person; verify their operator/requester and independent views. - Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or - IP addresses are refused even with the correct token. -2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast - member and filmography credit; check real facts, related titles, episode votes, - stage/actor labels, card dwell ratings and continuous See all loading. -3. Make the sandbox return 503 after a successful read. Reopen: saved content and - Saved time remain, requests cannot submit, and Try again performs another read. -4. Return 401, then restore authentication. Verify ratings/request actions disappear - and this person's saved Conductarr data is cleared. Try again restores data - without changing Jellyfin login or copying a token. -5. Have the sandbox accept a Shared-on-behalf request but lose its POST response. - Use Check again: the receipt says Shared, and the sandbox records one POST. -6. Restore the blank/default URL when the dedicated TV hostname and server's - 22-operation authority are deployed. This checklist is manual, not a live gate. - -Title and person surfaces publish saved timestamps through the same per-person -`RememberedView` partition and nav Saved indicator. Their timed notices render -the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage` -without transport details. A selected-person mismatch never renders the former -person's title/person projection. Title and performer disk copies remain isolated -by both server and authenticated user; native retry and cache-isolation regressions -exercise these boundaries without a live service. - -## TV fidelity and source gates - -Library/Discover page headings use the existing exact CSS line-box layout. -Request sheets reserve the inherited inline text descent around their ratings; -person rows and receipt panels therefore share the locked frame's vertical -coordinates. Settings row title/facts also retain their CSS line boxes. These -are local component fixes: shared typography tokens, Home and caption styling -do not shift. - -Home ratings observations use independent six-hour, 300-entry title and -series/season caches. A season retains each observed episode's own score and -timestamp; changing a Jellyfin episode UUID does not lose the observation, and -another episode or season never borrows that score. Episodes without a complete -catalogue identity remain isolated by their Jellyfin identity. - -Cold library grids publish each cumulative 200-item SDK chunk before the next -read completes. An existing complete saved copy stays visible throughout a -refresh, and only a completed read replaces it on disk. A cold later-chunk -failure keeps the already-readable titles with a retry tail, without saving an -incomplete copy. Appended titles preserve the focused title even when the -destination's sort inserts them before it. Catalogue row keys use the same -media-type/TMDB identity as their cards. - -Undo restores a dismissed pick silently; Show again announces “Back in your -picks.” Title pages own their own toast so the request adapter never duplicates -it. The saved Discover retry tail is labelled More picks, not Try again twice. - -People retry retains an already signed-in Conductarr client and its uncertain -submission ledger; only a signed-out identity is rebuilt. Home authorizes its -Jellyfin read by the selected person's saved Jellyfin user ID, so a lost -Conductarr operator or a renamed household profile does not hide that user's -own library. A different saved user ID still fails closed. - -`make test` runs `OwnedSourceAuditTest` through the design module's JVM suite. -It scans the owned Kotlin sources in `wolfskin-design/src/main/java` and -`app/src/wolfskin`, rejecting untokened `.dp`/`.sp` conversions and numeric Color -constructors outside generated tokens, token font sizes below 12 sp, raw -household library labels, and page-counter/navigation wording. Its negative -cases plant each violation independently. Existing native capture checks also -verify rendered font sizes and wording. `LibraryProgressiveSurfaceTest` captures -the usable first chunk while the final read is still suspended. - -## Toolchain and disk use - -`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools -`23.0` and platform-tools `37.0.1` by archive and checksum, and build-tools -`36.0.0` by SDK package version. The SDK manager registers platform `37.0` and -its licenses; the build rejects any platform revision other than 2. An SDK -revision upgrade therefore requires a deliberate Dockerfile update. -Gradle comes from the committed wrapper. The image is built for `linux/amd64`; -the default local image tag is `wolfskin-build:sdk37-jdk21`. -Upstream's build timestamp means APKs are not byte-for-byte reproducible. - -Gradle downloads and caches live in the named volume `wolfskin-gradle`, not -`/tmp`. Module outputs stay in the checkout. Builds run with the host UID/GID, -`--no-daemon`, and at most four workers. The repository's Git metadata is mounted -read-only, including when the checkout is a linked worktree. - -Override `WOLFSKIN_BUILD_IMAGE` to change the locally built image tag and -`WOLFSKIN_GRADLE_VOLUME` to isolate a cache. `make build-image` builds only the -image. Do not delete shared caches during another build. - -Every Gradle container holds a host-side exclusive lock for its entire run. -Concurrent targets sharing a cache queue rather than timing out on Gradle's -cross-container cache journal lock; a waiting invocation prints one line. -The default lock is `/var/tmp/${WOLFSKIN_GRADLE_VOLUME}.gradle.lock` (using -`wolfskin-gradle` when unset). Override `WOLFSKIN_GRADLE_LOCK_FILE` with a path -whose parent directory already exists. All callers sharing a volume must use -the same lock path on the Docker host. Do not delete the file while builds are -running or waiting. The lock releases automatically when the invocation exits. - -## Release credentials - -The default escrow is -`/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin/`. Check -`mountpoint -q /mnt/ventoy` before using that path. Set `WOLFSKIN_SECRETS_DIR` -to use another existing directory. Its required files are: - -| File | Contents | -| --- | --- | -| `release.jks` | Separate Wolfskin PKCS12 keystore, RSA 4096, alias `wolfskin` | -| `release.properties` | Java properties `storePassword` and `keyPassword` | -| `github-packages-token` | GitHub token with `read:packages`, username `JustinZeus` | - -The directory must be mode `0700`, each file mode `0600`, owned by the building -user. Never commit them or put their contents in logs. Back up the keystore and -passwords together: losing the release key forces a reinstall. Do not reuse -wolfskin-android's key or the gh CLI login token. - -Release builds mount escrow read-only. The token is loaded inside the container -into upstream's `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and -`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` environment hooks, never a Docker -command argument. The signing init script registers `wolfskin`; the nonsecret -`release-signing.properties` is mounted as `local.properties` so upstream's -`release.signing.config` hook selects it. Any host `local.properties` is hidden -only for the duration of this container. Keystore and passwords stay outside the -checkout and image. Missing credentials fail the release rather than silently -shipping the MPV stub. - -## Versioning and local release markers - -Upstream computes `versionName` from -`git describe --tags --long --match='v*'`, dropping the leading `v`, and computes -`versionCode` from the count of all local `v*` and `p*` tags. Preserve and fetch -those tags; a shallow or incomplete checkout can give an incorrect version. -Use the full `git describe` output as the human release name. - -After committing the intended release, with a clean checkout and fetched tags: - -```sh -make release-tag -make apk -``` - -The helper creates one lightweight `pwolfskin-YYYY_MM_DD` tag in UTC, appending -`_1`, `_2`, etc. on collision. Each marker adds one to `versionCode` without -changing upstream's `v*`-based version name. It never pushes tags, publishes a -release, or installs an APK. Do not delete published release markers. Tag -creation and publication must be serialized between release operators. - -## Inspecting release artifacts - -Use `apksigner verify --verbose --print-certs APK` and -`aapt2 dump badging APK` inside the build image. Compare the signer SHA-256 with -`keytool -list -v -alias wolfskin -keystore /path/to/release.jks` (enter its -password privately). Inspect APK ZIP entries for real native MPV and ffmpeg -libraries for each shipped ABI; a successful Java compilation alone does not -prove that codec extensions are included. diff --git a/docker/gradle.sh b/docker/gradle.sh deleted file mode 100755 index b5624cbf..00000000 --- a/docker/gradle.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Run the upstream wrapper with a persistent cache and read-only build credentials. -set -euo pipefail -mode=$1 -shift -root=$(git rev-parse --show-toplevel) -git_common=$(git rev-parse --path-format=absolute --git-common-dir) -image=${WOLFSKIN_BUILD_IMAGE:-wolfskin-build:sdk37-jdk21} -cache=${WOLFSKIN_GRADLE_VOLUME:-wolfskin-gradle} -secrets=${WOLFSKIN_SECRETS_DIR:-/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin} -lock=${WOLFSKIN_GRADLE_LOCK_FILE:-/var/tmp/${cache}.gradle.lock} -exec {lock_fd}>"$lock" -if ! flock --exclusive --nonblock "$lock_fd"; then - printf 'Waiting for Gradle cache lock: %s\n' "$lock" >&2 - flock --exclusive "$lock_fd" -fi -arguments=(--rm --platform linux/amd64 --user "$(id -u):$(id -g)" - --mount "type=bind,src=$root,dst=$root" --workdir "$root" - --mount "type=volume,src=$cache,dst=/gradle" - --mount "type=bind,src=$git_common,dst=$git_common,readonly") -gradle_arguments=(--no-daemon --console=plain --max-workers=4) -if [[ $mode == release ]]; then - for name in release.jks release.properties github-packages-token; do - if [[ ! -s $secrets/$name ]]; then - printf 'Missing build credential: %s/%s\n' "$secrets" "$name" >&2 - exit 1 - fi - done - arguments+=(--mount "type=bind,src=$secrets,dst=/run/wolfskin,readonly" - --mount "type=bind,src=$root/docker/release-signing.properties,dst=$root/local.properties,readonly") - gradle_arguments+=(--init-script /opt/wolfskin/signing.init.gradle) - docker run "${arguments[@]}" "$image" bash -euc ' - export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus - export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/wolfskin/github-packages-token) - exec ./gradlew "$@" - ' bash "${gradle_arguments[@]}" "$@" -else - docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" -fi diff --git a/docker/release-signing.properties b/docker/release-signing.properties deleted file mode 100644 index 7e15afe4..00000000 --- a/docker/release-signing.properties +++ /dev/null @@ -1 +0,0 @@ -release.signing.config=wolfskin diff --git a/docker/release-tag.sh b/docker/release-tag.sh deleted file mode 100755 index 14b2637c..00000000 --- a/docker/release-tag.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Add one lightweight release marker without publishing it. -set -euo pipefail -if [[ -n $(git status --porcelain) ]]; then - printf 'Commit or discard checkout changes before tagging a release.\n' >&2 - exit 1 -fi -base="pwolfskin-$(date -u +%Y_%m_%d)" -tag=$base -sequence=1 -while git show-ref --verify --quiet "refs/tags/$tag"; do - tag="${base}_${sequence}" - sequence=$((sequence + 1)) -done -git tag "$tag" -printf 'Created %s locally; no tag was pushed.\n' "$tag" -printf 'versionName: %s\n' "$(git describe --tags --long --match='v*' | cut -c 2-)" -printf 'versionCode: %s\n' "$(git tag --list 'v*' 'p*' | wc -l)" diff --git a/docker/signing.init.gradle b/docker/signing.init.gradle deleted file mode 100644 index b07a4770..00000000 --- a/docker/signing.init.gradle +++ /dev/null @@ -1,17 +0,0 @@ -// Keep signing material outside the checkout; upstream selects this configuration via local.properties. -allprojects { - pluginManager.withPlugin('com.android.application') { - def passwords = new Properties() - file('/run/wolfskin/release.properties').withInputStream { passwords.load(it) } - android.signingConfigs.create('wolfskin') { - storeFile = file('/run/wolfskin/release.jks') - storePassword = passwords.getProperty('storePassword') - keyPassword = passwords.getProperty('keyPassword') - keyAlias = 'wolfskin' - enableV1Signing = true - enableV2Signing = true - enableV3Signing = true - enableV4Signing = true - } - } -} diff --git a/gradle.properties b/gradle.properties index 6c29e7b3..132244e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,14 +21,3 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.defaults.buildfeatures.resvalues=false -android.sdk.defaultTargetSdkToCompileSdkIfUnset=true -android.enableAppCompileTimeRClass=true -android.usesSdkInManifest.disallowed=true -android.uniquePackageNames=true -android.dependency.useConstraints=false -android.enableR8.fullMode=true -android.r8.strictFullModeForKeepRules=true -android.r8.optimizedResourceShrinking=true -android.builtInKotlin=true -android.newDsl=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d1d45b2..a7858026 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,67 +1,41 @@ [versions] -aboutLibraries = "15.3.0" -acra = "5.14.2" -agp = "9.5.0" +aboutLibraries = "13.1.0" +acra = "5.13.1" +agp = "8.13.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltNavigationCompose = "1.4.0" -hiltCompiler = "1.4.0" -kache = "2.1.1" -kotlin = "2.4.21" -ksp = "2.3.9" -coreKtx = "1.19.1" -appcompat = "1.8.0" -composeBom = "2026.09.00" -mockk = "1.14.11" -profileinstaller = "1.4.1" -robolectric = "4.17" -multiplatformMarkdownRenderer = "0.46.0" -okhttpBom = "5.5.1" +hiltNavigationCompose = "1.3.0" +kotlin = "2.2.21" +ksp = "2.3.0" +coreKtx = "1.17.0" +appcompat = "1.7.1" +composeBom = "2025.11.01" +compose-runtime = "1.9.5" +multiplatformMarkdownRenderer = "0.38.1" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" -tvFoundation = "1.0.0" -tvMaterial = "1.1.0-alpha01" -lifecycleRuntimeKtx = "2.11.0" -activityCompose = "1.13.0" -androidx-media3 = "1.11.1" -# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT -exoplayerhdrutils = "0.4.1" -coil = "3.6.3" +tvFoundation = "1.0.0-alpha12" +tvMaterial = "1.0.1" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.0" +androidx-media3 = "1.8.0" +coil = "3.3.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.1.7" -lifecycleViewmodelNav3 = "2.11.0" -material3AdaptiveNav3 = "1.3.0" -protobuf = "0.10.0" -datastore = "1.2.1" -kotlinx-serialization = "1.12.0" -protobuf-javalite = "4.36.2" -hilt = "2.60.1" -room = "2.8.5" +nav3Core = "1.0.0" +lifecycleViewmodelNav3 = "2.10.0" +material3AdaptiveNav3 = "1.0.0-alpha03" +protobuf = "0.9.5" +datastore = "1.2.0" +kotlinx-serialization = "1.9.0" +protobuf-javalite = "4.33.1" +hilt = "2.57.2" +room = "2.8.4" +material3 = "1.4.0" preferenceKtx = "1.2.1" -tvprovider = "1.1.0" -workRuntimeKtx = "2.11.2" -paletteKtx = "1.0.0" -libass-android = "0.5.1" -kotlinxCoroutinesTest = "1.11.0" -coreTesting = "2.2.0" -openapi-generator = "7.26.0" -runner = "1.7.0" -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" } -wholphin-extensions-ffmpeg = { module = "com.github.damontecres.wholphin.media3:decoder-ffmpeg", version.ref = "wholphin-extensions" } -wholphin-extensions-av1 = { module = "com.github.damontecres.wholphin.media3:decoder-av1", version.ref = "wholphin-extensions" } aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutLibraries" } acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } @@ -76,37 +50,23 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime-android", version.ref = "compose-runtime" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "compose-runtime" } -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" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } -androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } -androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } -androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } -androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltCompiler" } -androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } - 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" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } -hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } -kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } -kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } -mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } -mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } -robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } -okhttp = { module = "com.squareup.okhttp3:okhttp" } -okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -114,13 +74,9 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } -androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } -androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } -androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } -exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } @@ -146,19 +102,10 @@ slf4j2-timber = { module = "uk.kulikov:slf4j2-timber", version.ref = "slf4j2Timb timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } -androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } -androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } -androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } - -ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "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" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } @@ -167,5 +114,3 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } 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.jar b/gradle/wrapper/gradle-wrapper.jar index 9fa7f288..e708b1c0 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 91c4fc64..fbff848d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,6 @@ +#Sat Sep 20 16:26:22 EDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 249efbb0..4f906e0c 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/bin/sh +#!/usr/bin/env sh # -# Copyright © 2015 the original authors. +# Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,114 +15,81 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## -# -# gradlew start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh gradlew -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# +## +## Gradle start up script for UN*X +## ############################################################################## # Attempt to set APP_HOME - # Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum +MAX_FD="maximum" warn () { echo "$*" -} >&2 +} die () { echo echo "$*" echo exit 1 -} >&2 +} # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java + JAVACMD="$JAVA_HOME/jre/sh/java" else - JAVACMD=$JAVA_HOME/bin/java + JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -131,118 +98,88 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." - fi fi # Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi fi -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi # For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=$( cygpath --unix "$JAVACMD" ) + JAVACMD=`cygpath --unix "$JAVACMD"` - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac fi +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 03754c4c..ac1b06f9 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,49 +13,19 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem -@if "%DEBUG%"=="" @echo off +@if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -@rem Catch executions from older scripts and ensure they exit cleanly. -@rem This can be removed once we can be reasonably confident that few people -@rem will be migrating directly to this new wrapper. -goto afterSafetyNet -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -goto exitWithErrorLevel -:afterSafetyNet +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused +if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -70,16 +40,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute +if "%ERRORLEVEL%" == "0" goto execute -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -87,26 +56,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -1>&2 echo. -1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -1>&2 echo. -1>&2 echo Please set the JAVA_HOME variable in your environment to match the -1>&2 echo location of your Java installation. +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. -"%COMSPEC%" /c exit 1 -goto exitWithErrorLevel +goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@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 & goto exitWithErrorLevel +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* -@rem This label must not be changed. We rely on old scripts being able to jump to this point. -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lint.xml b/lint.xml deleted file mode 100644 index 49e825b2..00000000 --- a/lint.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<lint> - <issue id="ExtraTranslation" severity="warning" /> -</lint> diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 5f9021d9..00000000 --- a/renovate.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ], - "packageRules": [ - { - "groupName": "Dependencies", - "matchDepTypes": ["dependencies"], - "separateMultipleMajor": true - }, - { - "groupName": "Github Actions", - "matchManagers": ["github-actions"], - "pinDigests": true - }, - { - "groupName": "Android TV Dependencies", - "matchPackageNames": [ - "androidx.tv:**" - ] - }, - { - "groupName": "Androidx Media3", - "matchPackageNames": [ - "androidx.media3:**" - ] - }, - { - "groupName": "Jellyfin SDK", - "matchPackageNames": [ - "org.jellyfin.sdk:**" - ], - "enabled": false - }, - { - "groupName": "AGP", - "matchPackageNames": [ - "com.android.application**" - ] - }, - { - "groupName": "Kotlin", - "matchPackageNames": [ - "org.jetbrains.kotlin**" - ] - }, - { - "groupName": "Internal dependencies", - "matchPackageNames": [ - "com.github.damontecres.wholphin**" - ], - "enabled": false - } - ] -} diff --git a/scripts/ffmpeg/README.md b/scripts/ffmpeg/README.md new file mode 100644 index 00000000..d1fdac02 --- /dev/null +++ b/scripts/ffmpeg/README.md @@ -0,0 +1,3 @@ +# FFmpeg ExoPlayer decoder extension module + +Builds the ffmpeg decoder extension module for `ExoPlayer` which supports extra codecs during playback. diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh new file mode 100755 index 00000000..7dd28133 --- /dev/null +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -ex + +if [ -z "$1" ]; then + echo "Error: Must provide NDK path" + exit 1 +fi +NDK_PATH="$1" + +SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")" +SCRIPT_DIR="$(dirname "${SCRIPT_PATH}")" +PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" + +# Config +ANDROID_ABI=21 +ENABLED_DECODERS=(dca ac3 eac3 mlp truehd) +FFMPEG_BRANCH="release/6.0" + +# Path configs +DIR_PATH="$(pwd)" +TARGET_PATH="$PROJECT_ROOT/app/libs" +MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" +FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" +FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" +HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" +HOST_PLATFORM="$HOST-x86_64" + +mkdir -p "$TARGET_PATH" +mkdir -p ffmpeg_decoder + +echo "$PROJECT_ROOT/gradle/libs.versions.toml" + +media_version="$(grep "androidx-media3 = " "$PROJECT_ROOT/gradle/libs.versions.toml" | awk -F'"' '{print $2}')" + +pushd ffmpeg_decoder || exit + +if [[ -d media ]]; then + pushd media || exit + git checkout --force "$media_version" +else + git clone https://github.com/androidx/media.git --depth 1 --single-branch -b "$media_version" media +fi + +if [[ -d ffmpeg ]]; then + pushd ffmpeg || exit + git checkout --force "$FFMPEG_BRANCH" +else + git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg +fi + +ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" + +pushd "${FFMPEG_MODULE_PATH}/jni" || exit + +./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" + +pushd "$MEDIA_PATH" || exit +./gradlew :lib-decoder-ffmpeg:assemble +popd || exit + +popd || exit +cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" +popd || exit diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md new file mode 100644 index 00000000..80eeb407 --- /dev/null +++ b/scripts/mpv/README.md @@ -0,0 +1,7 @@ +# MPV build scripts + +This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae0d956c5a98ab8bf25af7e2c73bcb59e19c15b7/buildscripts licensed MIT. + +## Instructions + +TODO diff --git a/scripts/mpv/buildall.sh b/scripts/mpv/buildall.sh new file mode 100755 index 00000000..56208015 --- /dev/null +++ b/scripts/mpv/buildall.sh @@ -0,0 +1,180 @@ +#!/bin/bash -e + +cd "$( dirname "${BASH_SOURCE[0]}" )" +. ./include/depinfo.sh + +cleanbuild=0 +nodeps=0 +clang=1 +target=mpv-android +arch=armv7l + +getdeps () { + varname="dep_${1//-/_}[*]" + echo ${!varname} +} + +loadarch () { + unset CC CXX CPATH LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH + unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS + + local apilvl=21 + # ndk_triple: what the toolchain actually is + # cc_triple: what Google pretends the toolchain is + if [ "$1" == "armv7l" ]; then + export ndk_suffix= + export ndk_triple=arm-linux-androideabi + cc_triple=armv7a-linux-androideabi$apilvl + prefix_name=armv7l + elif [ "$1" == "arm64" ]; then + export ndk_suffix=-arm64 + export ndk_triple=aarch64-linux-android + cc_triple=$ndk_triple$apilvl + prefix_name=arm64 + elif [ "$1" == "x86" ]; then + export ndk_suffix=-x86 + export ndk_triple=i686-linux-android + cc_triple=$ndk_triple$apilvl + prefix_name=x86 + elif [ "$1" == "x86_64" ]; then + export ndk_suffix=-x64 + export ndk_triple=x86_64-linux-android + cc_triple=$ndk_triple$apilvl + prefix_name=x86_64 + else + echo "Invalid architecture" >&2 + exit 1 + fi + export prefix_dir="$PWD/prefix/$prefix_name" + if [ $clang -eq 1 ]; then + export CC=$cc_triple-clang + export CXX=$cc_triple-clang++ + else + export CC=$cc_triple-gcc + export CXX=$cc_triple-g++ + fi + export LDFLAGS="-Wl,-O1,--icf=safe -Wl,-z,max-page-size=16384" + export AR=llvm-ar + export RANLIB=llvm-ranlib +} + +setup_prefix () { + if [ ! -d "$prefix_dir" ]; then + mkdir -p "$prefix_dir" + # enforce flat structure (/usr/local -> /) + ln -s . "$prefix_dir/usr" + ln -s . "$prefix_dir/local" + fi + + local cpu_family=${ndk_triple%%-*} + [ "$cpu_family" == "i686" ] && cpu_family=x86 + + if ! command -v pkg-config >/dev/null; then + echo "pkg-config not provided!" + return 1 + fi + + # meson wants to be spoonfed this file, so create it ahead of time + # also define: release build, static libs and no source downloads at runtime(!!!) + cat >"$prefix_dir/crossfile.tmp" <<CROSSFILE +[built-in options] +buildtype = 'release' +default_library = 'static' +wrap_mode = 'nodownload' +prefix = '/usr/local' +[binaries] +c = '$CC' +cpp = '$CXX' +ar = 'llvm-ar' +nm = 'llvm-nm' +strip = 'llvm-strip' +pkgconfig = 'pkg-config' +pkg-config = 'pkg-config' +[host_machine] +system = 'android' +cpu_family = '$cpu_family' +cpu = '${CC%%-*}' +endian = 'little' +CROSSFILE + # also avoid rewriting it needlessly + if cmp -s "$prefix_dir"/crossfile.{tmp,txt}; then + rm "$prefix_dir/crossfile.tmp" + else + mv "$prefix_dir"/crossfile.{tmp,txt} + fi +} + +build () { + if [ $1 != "mpv-android" ] && [ ! -d deps/$1 ]; then + printf >&2 '\e[1;31m%s\e[m\n' "Target $1 not found" + return 1 + fi + if [ $nodeps -eq 0 ]; then + printf >&2 '\e[1;34m%s\e[m\n' "Preparing $1..." + local deps=$(getdeps $1) + echo >&2 "Dependencies: $deps" + for dep in $deps; do + build $dep + done + fi + printf >&2 '\e[1;34m%s\e[m\n' "Building $1..." + if [ "$1" == "mpv-android" ]; then + pushd .. + BUILDSCRIPT=buildscripts/scripts/$1.sh + else + pushd deps/$1 + BUILDSCRIPT=../../scripts/$1.sh + fi + [ $cleanbuild -eq 1 ] && $BUILDSCRIPT clean + $BUILDSCRIPT build + popd +} + +usage () { + printf '%s\n' \ + "Usage: buildall.sh [options] [target]" \ + "Builds the specified target (default: $target)" \ + "-n Do not build dependencies" \ + "--clean Clean build dirs before compiling" \ + "--gcc Use gcc compiler (unsupported!)" \ + "--arch <arch> Build for specified architecture (default: $arch; supported: armv7l, arm64, x86, x86_64)" + exit 0 +} + +while [ $# -gt 0 ]; do + case "$1" in + --clean) + cleanbuild=1 + ;; + -n|--no-deps) + nodeps=1 + ;; + --gcc) + clang=0 + ;; + --arch) + shift + arch=$1 + ;; + -h|--help) + usage + ;; + -*) + echo "Unknown flag $1" >&2 + exit 1 + ;; + *) + target=$1 + ;; + esac + shift +done + +loadarch $arch +setup_prefix +build $target + +[ "$target" == "mpv-android" ] && \ + ls -lh ../app/build/outputs/apk/{default,api29}/*/*.apk + +exit 0 diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh new file mode 100755 index 00000000..f94c5c8e --- /dev/null +++ b/scripts/mpv/get_dependencies.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -exou pipefail + +BUILD_PATH="./deps" + +mkdir -p "$BUILD_PATH" +pushd "$BUILD_PATH" || exit + +function clone(){ + repo=$1 + branch=$2 + dir=$3 + shift 3 + + if [[ -d "$dir" ]]; then + pushd "$dir" || exit + git checkout --force "$branch" + popd || exit + else + git clone "$repo" --depth 1 --single-branch -b "$branch" "$dir" "$@" + fi +} + +clone "https://github.com/videolan/dav1d" "1.5.2" dav1d + +clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg + +clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules + +clone "https://github.com/libass/libass" "master" libass + +clone "https://github.com/haasn/libplacebo" "master" libplacebo --recurse-submodules + +clone "https://github.com/mpv-player/mpv" "master" mpv + +if [[ ! -d mbedtls ]]; then + mkdir mbedtls + wget https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-3.6.4/mbedtls-3.6.4.tar.bz2 -O - | \ + tar -xj -C mbedtls --strip-components=1 +fi + +if [[ ! -d fribidi ]]; then + mkdir fribidi + wget https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz -O - | \ + tar -xJ -C fribidi --strip-components=1 +fi + +if [[ ! -d harfbuzz ]]; then + mkdir harfbuzz + wget https://github.com/harfbuzz/harfbuzz/releases/download/12.1.0/harfbuzz-12.1.0.tar.xz -O - | \ + tar -xJ -C harfbuzz --strip-components=1 +fi + +version_unibreak="6.1" +if [[ ! -d unibreak ]]; then + mkdir unibreak + wget https://github.com/adah1972/libunibreak/releases/download/libunibreak_${version_unibreak//./_}/libunibreak-${version_unibreak}.tar.gz -O - | \ + tar -xz -C unibreak --strip-components=1 +fi + +if [[ ! -d lua ]]; then + mkdir lua + wget https://www.lua.org/ftp/lua-5.2.4.tar.gz -O - | \ + tar -xz -C lua --strip-components=1 +fi + +# python packages: jsonschema jinja2 meson + +popd || exit diff --git a/scripts/mpv/include/ci.sh b/scripts/mpv/include/ci.sh new file mode 100755 index 00000000..19b44a0b --- /dev/null +++ b/scripts/mpv/include/ci.sh @@ -0,0 +1,91 @@ +#!/bin/bash -e + +# go to buildscripts root folder +cd "$( dirname "${BASH_SOURCE[0]}" )/.." + +. ./include/depinfo.sh + +msg() { + printf '==> %s\n' "$1" +} + +fetch_prefix() { + if [[ "$CACHE_MODE" == folder ]]; then + local text= + if [ -f "$CACHE_FOLDER/id.txt" ]; then + text=$(cat "$CACHE_FOLDER/id.txt") + else + echo "Cache seems to be empty" + fi + printf 'Expecting "%s",\nfound "%s".\n' "$ci_tarball" "$text" + if [[ "$text" == "$ci_tarball" ]]; then + tar -xzf "$CACHE_FOLDER/data.tgz" -C prefix && return 0 + fi + fi + return 1 +} + +build_prefix() { + msg "Building the prefix ($ci_tarball)..." + + msg "Fetching deps" + IN_CI=1 ./include/download-deps.sh + + # build everything mpv depends on (but not mpv itself) + for x in ${dep_mpv[@]}; do + msg "Building $x" + ./buildall.sh $x + done + + if [[ "$CACHE_MODE" == folder && -w "$CACHE_FOLDER" ]]; then + msg "Compressing the prefix" + tar -cvzf "$CACHE_FOLDER/data.tgz" -C prefix . + echo "$ci_tarball" >"$CACHE_FOLDER/id.txt" + fi +} + +export WGET="wget --progress=bar:force" + +if [ "$1" = "export" ]; then + # export variable with unique cache identifier + echo "CACHE_IDENTIFIER=$ci_tarball" + exit 0 +elif [ "$1" = "install" ]; then + # install deps + if [[ -n "$ANDROID_HOME" && -d "$ANDROID_HOME" ]]; then + msg "Linking existing SDK" + mkdir -p sdk + ln -sv "$ANDROID_HOME" sdk/android-sdk-linux + fi + + msg "Fetching SDK + NDK" + IN_CI=1 ./include/download-sdk.sh + + msg "Fetching mpv" + mkdir -p deps/mpv + $WGET https://github.com/mpv-player/mpv/archive/master.tar.gz -O master.tgz + tar -xzf master.tgz -C deps/mpv --strip-components=1 + rm master.tgz + + msg "Trying to fetch existing prefix" + mkdir -p prefix + fetch_prefix || build_prefix + exit 0 +elif [ "$1" = "build" ]; then + # run build + : +else + exit 1 +fi + +msg "Building mpv" +./buildall.sh -n mpv || { + # show logfile if configure failed + [ ! -f deps/mpv/_build/config.h ] && cat deps/mpv/_build/meson-logs/meson-log.txt + exit 1 +} + +msg "Building mpv-android" +./buildall.sh -n + +exit 0 diff --git a/scripts/mpv/include/depinfo.sh b/scripts/mpv/include/depinfo.sh new file mode 100755 index 00000000..875e3687 --- /dev/null +++ b/scripts/mpv/include/depinfo.sh @@ -0,0 +1,43 @@ +#!/bin/bash -e + +## Dependency versions +# Make sure to keep v_ndk and v_ndk_n in sync, both are listed on the NDK download page + +v_sdk=11076708_latest +v_ndk=r29 +v_ndk_n=29.0.14206865 +v_sdk_platform=35 +v_sdk_build_tools=35.0.0 + +v_lua=5.2.4 +v_unibreak=6.1 +v_harfbuzz=12.1.0 +v_fribidi=1.0.16 +v_freetype=2.14.1 +v_mbedtls=3.6.4 + + +## Dependency tree +# I would've used a dict but putting arrays in a dict is not a thing + +dep_mbedtls=() +dep_dav1d=() +dep_ffmpeg=(mbedtls dav1d) +dep_freetype2=() +dep_fribidi=() +dep_harfbuzz=() +dep_unibreak=() +dep_libass=(freetype2 fribidi harfbuzz unibreak) +dep_lua=() +dep_libplacebo=() +dep_mpv=(ffmpeg libass lua libplacebo) +dep_mpv_android=(mpv) + + +## for CI workflow + +# pinned ffmpeg revision +v_ci_ffmpeg=n8.0 + +# filename used to uniquely identify a build prefix +ci_tarball="prefix-ndk-${v_ndk}-lua-${v_lua}-unibreak-${v_unibreak}-harfbuzz-${v_harfbuzz}-fribidi-${v_fribidi}-freetype-${v_freetype}-mbedtls-${v_mbedtls}-ffmpeg-${v_ci_ffmpeg}.tgz" diff --git a/scripts/mpv/include/path.sh b/scripts/mpv/include/path.sh new file mode 100755 index 00000000..4ab1073c --- /dev/null +++ b/scripts/mpv/include/path.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" + +. "$DIR/include/depinfo.sh" + +os=linux +[[ "$OSTYPE" == "darwin"* ]] && os=mac +export os + +if [ "$os" == "mac" ]; then + [ -z "$cores" ] && cores=$(sysctl -n hw.ncpu) + # various things rely on GNU behaviour + export INSTALL=`which ginstall` + export SED=gsed +else + [ -z "$cores" ] && cores=$(grep -c ^processor /proc/cpuinfo) +fi +cores=${cores:-4} + +# configure pkg-config paths if inside buildscripts +if [ -n "$ndk_triple" ]; then + export PKG_CONFIG_SYSROOT_DIR="$prefix_dir" + export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/lib/pkgconfig" + unset PKG_CONFIG_PATH +fi + +toolchain=$(echo "$DIR/sdk/android-ndk-${v_ndk}/toolchains/llvm/prebuilt/"*) +[ -d "$toolchain" ] && \ + export PATH="$toolchain/bin:$DIR/sdk/android-ndk-${v_ndk}:$DIR/sdk/bin:$PATH" +export ANDROID_HOME="$DIR/sdk/android-sdk-$os" +unset ANDROID_SDK_ROOT ANDROID_NDK_ROOT diff --git a/scripts/mpv/scripts/dav1d.sh b/scripts/mpv/scripts/dav1d.sh new file mode 100755 index 00000000..aef89de7 --- /dev/null +++ b/scripts/mpv/scripts/dav1d.sh @@ -0,0 +1,22 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX # meson wants these unset + +meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ + -Denable_tests=false -Db_lto=true -Dstack_alignment=16 + +ninja -C $build -j$cores +DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/ffmpeg.sh b/scripts/mpv/scripts/ffmpeg.sh new file mode 100755 index 00000000..c9c73a74 --- /dev/null +++ b/scripts/mpv/scripts/ffmpeg.sh @@ -0,0 +1,48 @@ +#!/bin/bash -e + +. ../../include/path.sh + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf _build$ndk_suffix + exit 0 +else + exit 255 +fi + +mkdir -p _build$ndk_suffix +cd _build$ndk_suffix + +cpu=armv7-a +[[ "$ndk_triple" == "aarch64"* ]] && cpu=armv8-a +[[ "$ndk_triple" == "x86_64"* ]] && cpu=generic +[[ "$ndk_triple" == "i686"* ]] && cpu="i686 --disable-asm" + +cpuflags= +[[ "$ndk_triple" == "arm"* ]] && cpuflags="$cpuflags -mfpu=neon -mcpu=cortex-a8" + +args=( + --target-os=android --enable-cross-compile + --cross-prefix=$ndk_triple- --cc=$CC --pkg-config=pkg-config --nm=llvm-nm + --arch=${ndk_triple%%-*} --cpu=$cpu + --extra-cflags="-I$prefix_dir/include $cpuflags" --extra-ldflags="-L$prefix_dir/lib" + + --enable-{jni,mediacodec,mbedtls,libdav1d} --disable-vulkan + --disable-static --enable-shared --enable-{gpl,version3} + + # disable unneeded parts + --disable-{stripping,doc,programs} + # to keep the build lean we disable some feature quite aggressively: + # - muxers, encoders: mpv-android does not have any way to use these + # - devices: no practical use on Android + --disable-{muxers,encoders,devices} + # useful to taking screenshots + --enable-encoder=mjpeg,png + # useful for the `dump-cache` command + --enable-muxer=mov,matroska,mpegts +) +../configure "${args[@]}" + +make -j$cores +make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/freetype2.sh b/scripts/mpv/scripts/freetype2.sh new file mode 100755 index 00000000..deceb0b4 --- /dev/null +++ b/scripts/mpv/scripts/freetype2.sh @@ -0,0 +1,21 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX # meson wants these unset + +meson setup $build --cross-file "$prefix_dir"/crossfile.txt + +ninja -C $build -j$cores +DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/fribidi.sh b/scripts/mpv/scripts/fribidi.sh new file mode 100755 index 00000000..a9e22aa5 --- /dev/null +++ b/scripts/mpv/scripts/fribidi.sh @@ -0,0 +1,22 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX # meson wants these unset + +meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ + -D{tests,docs}=false + +ninja -C $build -j$cores +DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/harfbuzz.sh b/scripts/mpv/scripts/harfbuzz.sh new file mode 100755 index 00000000..c1d074f6 --- /dev/null +++ b/scripts/mpv/scripts/harfbuzz.sh @@ -0,0 +1,22 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX # meson wants these unset + +meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ + -Dtests=disabled -Ddocs=disabled + +ninja -C $build -j$cores +DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/libass.sh b/scripts/mpv/scripts/libass.sh new file mode 100755 index 00000000..739916de --- /dev/null +++ b/scripts/mpv/scripts/libass.sh @@ -0,0 +1,25 @@ +#!/bin/bash -e + +. ../../include/path.sh + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf _build$ndk_suffix + exit 0 +else + exit 255 +fi + +[ -f configure ] || ./autogen.sh + +mkdir -p _build$ndk_suffix +cd _build$ndk_suffix + +../configure \ + --host=$ndk_triple --with-pic \ + --enable-static --disable-shared \ + --enable-libunibreak --disable-require-system-font-provider + +make -j$cores +make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/libplacebo.sh b/scripts/mpv/scripts/libplacebo.sh new file mode 100755 index 00000000..fac33d94 --- /dev/null +++ b/scripts/mpv/scripts/libplacebo.sh @@ -0,0 +1,25 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX +meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ + -Dvulkan=disabled -Ddemos=false + +ninja -C $build -j$cores +DESTDIR="$prefix_dir" ninja -C $build install + +# add missing library for static linking +# this isn't "-lstdc++" due to a meson bug: https://github.com/mesonbuild/meson/issues/11300 +${SED:-sed} '/^Libs:/ s|$| -lc++|' "$prefix_dir/lib/pkgconfig/libplacebo.pc" -i diff --git a/scripts/mpv/scripts/lua.sh b/scripts/mpv/scripts/lua.sh new file mode 100755 index 00000000..cc3c52ec --- /dev/null +++ b/scripts/mpv/scripts/lua.sh @@ -0,0 +1,44 @@ +#!/bin/bash -e + +. ../../include/path.sh + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + make clean + exit 0 +else + exit 255 +fi + +# Building seperately from source tree is not supported, this means we are forced to always clean +$0 clean + +mycflags=( + # ensures correct linking into libmpv.so + -fPIC + # bionic is missing decimal_point in localeconv [src/llex.c] + -Dgetlocaledecpoint\\\(\\\)=\\\(46\\\) + # force fallback as ftello/fseeko are not defined [src/liolib.c] + -Dlua_fseek +) + +# LUA_T= and LUAC_T= to disable building lua & luac +# -Dgetlocaledecpoint()=('.') fixes bionic missing decimal_point in localeconv +make CC="$CC" AR="$AR rc" RANLIB="$RANLIB" \ + MYCFLAGS="${mycflags[*]}" \ + PLAT=linux LUA_T= LUAC_T= -j$cores + +# TO_BIN=/dev/null disables installing lua & luac +make INSTALL=${INSTALL:-install} INSTALL_TOP="$prefix_dir" TO_BIN=/dev/null install + +# make pc only generates a partial pkg-config file because ???? +mkdir -p $prefix_dir/lib/pkgconfig +make pc >$prefix_dir/lib/pkgconfig/lua.pc +cat >>$prefix_dir/lib/pkgconfig/lua.pc <<'EOF' +Name: Lua +Description: +Version: ${version} +Libs: -L${libdir} -llua +Cflags: -I${includedir} +EOF diff --git a/scripts/mpv/scripts/mbedtls.sh b/scripts/mpv/scripts/mbedtls.sh new file mode 100755 index 00000000..32ae32f9 --- /dev/null +++ b/scripts/mpv/scripts/mbedtls.sh @@ -0,0 +1,22 @@ +#!/bin/bash -e + +. ../../include/path.sh + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + make clean + exit 0 +else + exit 255 +fi + +$0 clean # separate building not supported, always clean +if [[ "$ndk_triple" == "i686"* ]]; then + ./scripts/config.py unset MBEDTLS_AESNI_C +else + ./scripts/config.py set MBEDTLS_AESNI_C +fi + +make -j$cores no_test +make DESTDIR="$prefix_dir" install diff --git a/scripts/mpv/scripts/mpv-android.sh b/scripts/mpv/scripts/mpv-android.sh new file mode 100755 index 00000000..af152810 --- /dev/null +++ b/scripts/mpv/scripts/mpv-android.sh @@ -0,0 +1,77 @@ +#!/bin/bash -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +BUILD="$DIR/.." +MPV_ANDROID="$DIR/../.." + +. $BUILD/include/path.sh +. $BUILD/include/depinfo.sh + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $MPV_ANDROID/{app,.}/build $MPV_ANDROID/app/src/main/{libs,obj} + exit 0 +else + exit 255 +fi + +[ -n "$ANDROID_SIGNING_KEY" ] && BUNDLE=1 + +nativeprefix () { + if [ -f $BUILD/prefix/$1/lib/libmpv.so ]; then + echo $BUILD/prefix/$1 + else + echo >&2 "Warning: libmpv.so not found in native prefix for $1, support will be omitted" + fi +} + +prefix32=$(nativeprefix "armv7l") +prefix64=$(nativeprefix "arm64") +prefix_x64=$(nativeprefix "x86_64") +prefix_x86=$(nativeprefix "x86") + +if [[ -z "$prefix32" && -z "$prefix64" && -z "$prefix_x64" && -z "$prefix_x86" ]]; then + echo >&2 "Error: no mpv library detected." + exit 255 +fi + +PREFIX32=$prefix32 PREFIX64=$prefix64 PREFIX_X64=$prefix_x64 PREFIX_X86=$prefix_x86 \ +ndk-build -C app/src/main -j$cores + +targets=(assembleDebug) +if [ -z "$DONT_BUILD_RELEASE" ]; then + targets+=(assembleRelease) + [ -n "$BUNDLE" ] && targets+=(bundleRelease) +fi +./gradlew "${targets[@]}" + +if [ -n "$ANDROID_SIGNING_KEY" ]; then + cd "${MPV_ANDROID}/app/build/outputs/apk" + apksigner=${ANDROID_HOME}/build-tools/${v_sdk_build_tools}/apksigner + for v in default api29; do + pushd $v + # sign the universal debug APK + "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ + --in debug/app-$v-universal-debug.apk --out debug/app-$v-universal-debug-signed.apk + # but all of the release APKs + for apk in release/*-unsigned.apk; do + "$apksigner" sign --ks "${ANDROID_SIGNING_KEY}" \ + --in $apk --out ${apk/-unsigned/-signed} + done + popd + done + # and the bundle + cd ../bundle + if [ -n "$BUNDLE" ]; then + if [ -z "$ANDROID_SIGNING_ALIAS" ]; then + echo >&2 "Error: ANDROID_SIGNING_ALIAS must be set to use jarsigner" + exit 1 + fi + pushd defaultRelease + jarsigner -keystore "${ANDROID_SIGNING_KEY}" -signedjar \ + app-default-release-signed.aab app-default-release.aab \ + "${ANDROID_SIGNING_ALIAS}" + popd + fi +fi diff --git a/scripts/mpv/scripts/mpv.sh b/scripts/mpv/scripts/mpv.sh new file mode 100755 index 00000000..feb85c95 --- /dev/null +++ b/scripts/mpv/scripts/mpv.sh @@ -0,0 +1,30 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +unset CC CXX # meson wants these unset + +meson setup $build --cross-file "$prefix_dir"/crossfile.txt \ + --default-library shared \ + -Diconv=disabled -Dlua=enabled \ + -Dlibmpv=true -Dcplayer=false \ + -Dmanpage-build=disabled + +ninja -C $build -j$cores +if [ -f $build/libmpv.a ]; then + echo >&2 "Meson fucked up, forcing rebuild." + $0 clean + exec $0 build +fi +DESTDIR="$prefix_dir" ninja -C $build install diff --git a/scripts/mpv/scripts/unibreak.sh b/scripts/mpv/scripts/unibreak.sh new file mode 100755 index 00000000..d18dbea5 --- /dev/null +++ b/scripts/mpv/scripts/unibreak.sh @@ -0,0 +1,24 @@ +#!/bin/bash -e + +. ../../include/path.sh + +build=_build$ndk_suffix + +if [ "$1" == "build" ]; then + true +elif [ "$1" == "clean" ]; then + rm -rf $build + exit 0 +else + exit 255 +fi + +mkdir -p $build +cd $build + +../configure \ + --host=$ndk_triple --with-pic \ + --enable-static --disable-shared + +make -j$cores +make DESTDIR="$prefix_dir" install diff --git a/settings.gradle.kts b/settings.gradle.kts index 76b787aa..c9d815b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,6 +9,9 @@ pluginManagement { } mavenCentral() gradlePluginPortal() +// maven { +// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") +// } } } dependencyResolutionManagement { @@ -16,19 +19,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - @Suppress("ktlint:standard:property-naming") - val WholphinExtensionsUsername: String? by settings - if (!WholphinExtensionsUsername.isNullOrBlank()) { - maven("https://maven.pkg.github.com/damontecres/wholphin-extensions") { - name = "WholphinExtensions" - credentials(PasswordCredentials::class) - } - } +// maven { +// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") +// } } } rootProject.name = "Wholphin" include(":app") -include(":wholphin-mpv-stub") -include(":wolfskin-design") -include(":conductarr-client") diff --git a/wholphin-mpv-stub/.gitignore b/wholphin-mpv-stub/.gitignore deleted file mode 100644 index 796b96d1..00000000 --- a/wholphin-mpv-stub/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/wholphin-mpv-stub/README.md b/wholphin-mpv-stub/README.md deleted file mode 100644 index 67956101..00000000 --- a/wholphin-mpv-stub/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 9017f416..00000000 --- a/wholphin-mpv-stub/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index e69de29b..00000000 diff --git a/wholphin-mpv-stub/proguard-rules.pro b/wholphin-mpv-stub/proguard-rules.pro deleted file mode 100644 index f1b42451..00000000 --- a/wholphin-mpv-stub/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index 8bdb7e14..00000000 --- a/wholphin-mpv-stub/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?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 deleted file mode 100644 index d7c993f1..00000000 --- a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MPVLib.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index fd4e0e8d..00000000 --- a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvPlayer.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 713d40c3..00000000 --- a/wholphin-mpv-stub/src/main/java/com/github/damontecres/wholphin/mpv/MpvStubException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.github.damontecres.wholphin.mpv - -class MpvStubException : Exception("This build is using wholphin-mpv-stub which does not implement actual functionality") diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts deleted file mode 100644 index d1275edb..00000000 --- a/wolfskin-design/build.gradle.kts +++ /dev/null @@ -1,68 +0,0 @@ -import java.security.MessageDigest -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.compose) -} - -android { - namespace = "org.justinvisser.wolfskin.design" - compileSdk = libs.versions.compileSdk.get().toInt() - defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } - buildFeatures { compose = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - testOptions.unitTests.isIncludeAndroidResources = true -} -kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 - -val robolectricSdk by configurations.creating { - isCanBeResolved = true - isCanBeConsumed = false - isTransitive = false -} -val cacheRobolectricSdk by tasks.registering(Copy::class) { - from(robolectricSdk) - into(layout.buildDirectory.dir("robolectric-sdk")) -} - -val checkTokenDrift by tasks.registering { - inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) - doLast { - file("tokens.sha256").readLines().forEach { line -> - val (expected, name) = line.split(" ", limit = 2) - val actual = MessageDigest.getInstance("SHA-256").digest(file(name).readBytes()).joinToString("") { "%02x".format(it) } - check(actual == expected) { "Token drift in $name: run python3 wolfskin-design/generate_tokens.py" } - } - } -} -tasks.named("preBuild") { dependsOn(checkTokenDrift) } -tasks.withType<Test>().configureEach { - dependsOn(cacheRobolectricSdk) - systemProperty("robolectric.dependency.dir", layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) - systemProperty("robolectric.offline", "true") - systemProperty("robolectric.graphicsMode", "NATIVE") - systemProperty("roborazzi.test.record", "true") - systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) -} - -dependencies { - robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") - robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") - robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.tv.material) - implementation(libs.coil.compose) - debugImplementation(libs.androidx.compose.ui.test.manifest) - testImplementation(libs.junit) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - implementation(libs.androidx.compose.material3) - testImplementation(libs.androidx.compose.ui.test.junit4) - testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") - testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") -} diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json deleted file mode 100644 index 83266ff5..00000000 --- a/wolfskin-design/discover-tokens.json +++ /dev/null @@ -1,279 +0,0 @@ -{ - "discover": { - "sheetPaddingX": { - "$type": "dimension", - "px": 64, - "dp": 32, - "source": "locked index.html:227" - }, - "sheetPaddingY": { - "$type": "dimension", - "px": 80, - "dp": 40, - "source": "locked index.html:227" - }, - "sheetGap": { - "$type": "dimension", - "px": 18, - "dp": 9, - "source": "locked index.html:227" - }, - "sheetSubGap": { - "$type": "dimension", - "px": 14, - "dp": 7, - "source": "locked index.html:229" - }, - "sheetRowPadding": { - "$type": "dimension", - "px": 26, - "dp": 13, - "source": "locked index.html:231" - }, - "sheetAvatar": { - "$type": "dimension", - "px": 60, - "dp": 30, - "source": "locked index.html:232" - }, - "sheetRadio": { - "$type": "dimension", - "px": 40, - "dp": 20, - "source": "locked index.html:234" - }, - "sheetRadioDot": { - "$type": "dimension", - "px": 16, - "dp": 8, - "source": "locked index.html:236" - }, - "sheetPersonGap": { - "$type": "dimension", - "px": 24, - "dp": 12, - "source": "locked index.html:231" - }, - "sheetButtonTop": { - "$type": "dimension", - "px": 12, - "dp": 6, - "source": "locked index.html:241" - }, - "chipTop": { - "$type": "dimension", - "px": 16, - "dp": 8, - "source": "locked index.html:184" - }, - "chipHeight": { - "$type": "dimension", - "px": 68, - "dp": 34, - "source": "locked index.html:185" - }, - "chipPadding": { - "$type": "dimension", - "px": 28, - "dp": 14, - "source": "locked index.html:185" - }, - "chipGap": { - "$type": "dimension", - "px": 16, - "dp": 8, - "source": "locked index.html:184" - }, - "heroTitleGap": { - "$type": "dimension", - "px": 16, - "dp": 8, - "source": "locked index.html:103" - }, - "heroKickerGap": { - "$type": "dimension", - "px": 10, - "dp": 5, - "source": "locked index.html:101" - }, - "heroMetaGap": { - "$type": "dimension", - "px": 14, - "dp": 7, - "source": "locked index.html:104" - }, - "heroOverviewGap": { - "$type": "dimension", - "px": 30, - "dp": 15, - "source": "locked index.html:105" - }, - "heroFactsGap": { - "$type": "dimension", - "px": 22, - "dp": 11, - "source": "locked index.html:104" - }, - "heroFactsHeight": { - "$type": "dimension", - "px": 38, - "dp": 19, - "source": "locked index.html:104" - }, - "heroSheetRight": { - "$type": "dimension", - "px": 180, - "dp": 90, - "source": "locked index.html:97" - }, - "heroSheetTop": { - "$type": "dimension", - "px": 140, - "dp": 70, - "source": "locked index.html:98" - }, - "heroSheetWidth": { - "$type": "dimension", - "px": 360, - "dp": 180, - "source": "locked index.html:98" - }, - "heroSheetHeight": { - "$type": "dimension", - "px": 540, - "dp": 270, - "source": "locked index.html:98" - }, - "dotSize": { - "$type": "dimension", - "px": 14, - "dp": 7, - "source": "locked index.html:114" - }, - "dotActive": { - "$type": "dimension", - "px": 44, - "dp": 22, - "source": "locked index.html:115" - }, - "dotGap": { - "$type": "dimension", - "px": 14, - "dp": 7, - "source": "locked index.html:113" - }, - "dotMargin": { - "$type": "dimension", - "px": 18, - "dp": 9, - "source": "locked index.html:113" - }, - "dotChevron": { - "$type": "dimension", - "px": 40, - "dp": 20, - "source": "locked index.html:116" - }, - "stateTop": { - "$type": "dimension", - "px": 20, - "dp": 10, - "source": "locked index.html:194" - }, - "statePaddingX": { - "$type": "dimension", - "px": 40, - "dp": 20, - "source": "locked index.html:194" - }, - "statePaddingY": { - "$type": "dimension", - "px": 34, - "dp": 17, - "source": "locked index.html:194" - }, - "stateTextGap": { - "$type": "dimension", - "px": 24, - "dp": 12, - "source": "locked index.html:198" - }, - "noteTop": { - "$type": "dimension", - "px": 14, - "dp": 7, - "source": "locked index.html:191" - }, - "headerTop": { - "$type": "dimension", - "px": 150, - "dp": 75, - "source": "locked index.html:199" - }, - "headerGap": { - "$type": "dimension", - "px": 24, - "dp": 12, - "source": "locked index.html:202" - }, - "entryPaddingX": { - "$type": "dimension", - "px": 22, - "dp": 11, - "source": "locked index.html:149" - }, - "entryPaddingY": { - "$type": "dimension", - "px": 20, - "dp": 10, - "source": "locked index.html:149" - }, - "entryText": { - "$type": "typography", - "px": 30, - "sp": 15, - "weight": 800, - "letterSpacingEm": -0.01, - "$value": { - "fontSize": "15sp", - "fontWeight": 800 - }, - "source": "locked index.html:149" - }, - "sheetRadioRing": { - "$type": "dimension", - "px": 3, - "dp": 1.5, - "source": "locked index.html:234" - }, - "skeletonNameWidth": { - "$type": "dimension", - "px": 260, - "dp": 130, - "source": "locked index.html:1851" - }, - "skeletonNameHeight": { - "$type": "dimension", - "px": 34, - "dp": 17, - "source": "locked index.html:1851" - }, - "sheetSubTop": {"$type": "dimension", "px": -8, "dp": -4, "source": "locked index.html:229"}, - "sheetRatingTop": {"$type": "dimension", "px": -10, "dp": -5, "source": "locked index.html:168"}, - "sheetRatingBottom": {"$type": "dimension", "px": 6, "dp": 3, "source": "locked index.html:168"}, - "sheetLabelTop": {"$type": "dimension", "px": 8, "dp": 4, "source": "locked index.html:230"}, - "noteIcon": {"$type": "dimension", "px": 30, "dp": 15, "source": "locked index.html:192"}, - "noteGap": {"$type": "dimension", "px": 16, "dp": 8, "source": "locked index.html:191"}, - "frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"}, - "anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"}, - "anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"}, - "reasonOpacity": {"$type": "number", "$value": 0.8, "source": "locked index.html:240"}, - "stageGap": {"$type": "dimension", "px": 10, "dp": 5, "source": "locked index.html:220"}, - "entryBorderOpacity": {"$type": "number", "$value": 0.08, "source": "locked index.html:149"}, - "ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"}, - "ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"}, - "ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"}, - "ambientSaturation": {"$type": "number", "$value": 1.2, "source": "locked index.html:96"}, - "ambientBrightness": {"$type": "number", "$value": 0.55, "source": "locked index.html:96"} - } -} diff --git a/wolfskin-design/generate_request_marks.py b/wolfskin-design/generate_request_marks.py deleted file mode 100755 index bba6a960..00000000 --- a/wolfskin-design/generate_request_marks.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size.""" -from pathlib import Path -import hashlib -import json -import cairosvg - -ROOT = Path(__file__).resolve().parent -SOURCE = ROOT / "request-marks" -OUTPUT = ROOT / "src/main/res/drawable-nodpi" - - -def main() -> None: - """Keep the original SVGs and record hashes alongside generated television-size PNGs.""" - OUTPUT.mkdir(parents=True, exist_ok=True) - tokens = json.loads((ROOT / "tokens.json").read_text()) - supplement = json.loads((ROOT / "shell-tokens.json").read_text()) - height = int(tokens["type"]["label"]["px"]) - wordmark_ratio = supplement["requests"]["ratingsWordmarkRatio"]["$value"] - manifest = [] - for source in sorted(SOURCE.glob("*.svg")): - target = OUTPUT / f"coming_in_rating_{source.stem}.png" - width = round(height * wordmark_ratio) if source.stem in ("imdb", "tmdb") else height - cairosvg.svg2png(url=str(source), write_to=str(target), output_width=width, output_height=height) - for path in (source, target): - manifest.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT)}") - (ROOT / "request-marks.sha256").write_text("\n".join(manifest) + "\n") - - -if __name__ == "__main__": - main() diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py deleted file mode 100755 index 51a49b20..00000000 --- a/wolfskin-design/generate_tokens.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 -"""Generate Compose and CSS tokens and a build-verifiable drift manifest.""" -from __future__ import annotations - -import argparse -import hashlib -import json -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt") - - -def number(value: float) -> str: - """Render stable Kotlin decimal literals.""" - return str(float(value)) - - -def property_name(name: str) -> str: - """Keep source names valid as Kotlin identifiers.""" - return name.replace("-", "_") - - -def compose_weight(weight: int) -> str: - """Use the platform bold face for intermediate weights that resolve to Regular in native captures.""" - return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})" - - -def render_tokens(tokens: dict) -> tuple[str, str]: - """Translate locked values into platform-resolvable Compose and CSS tokens.""" - lines = [ - "// Generated by generate_tokens.py; edit the JSON sources instead.", - "package org.justinvisser.wolfskin.design", - "", - "import androidx.compose.ui.graphics.Color as ComposeColor", - "import androidx.compose.ui.text.TextStyle", - "import androidx.compose.ui.text.font.FontWeight", - "import androidx.compose.ui.unit.dp", - "import androidx.compose.ui.unit.em", - "import androidx.compose.ui.unit.sp", - "", - "/** The locked TV design, converted at the source density. */", - "object WolfskinTokens {", - ] - css = ["/* Generated from the locked TV token sources. */", ":root {"] - for group_name, group in tokens.items(): - if group_name.startswith("$") or group_name in ("meta", "font"): - continue - lines.append(f" object {group_name[0].upper() + group_name[1:]} {{") - for name, token in group.items(): - if not isinstance(token, dict): - continue - name = property_name(name) - kind = token.get("$type") - value = token.get("$value") - if kind == "color": - rgba = value.lstrip("#") - argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6] - lines.append(f" val {name} = ComposeColor(0x{argb.upper()})") - css.append(f" --{group_name}-{name}: {value};") - elif kind == "typography" and "sp" in token: - size = token["sp"] - weight = token.get("weight", token["$value"]["fontWeight"]) - height = size * token.get("lineHeight", 1.3) - tracking = token.get("letterSpacingEm", 0) - lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)") - css.append(f" --{group_name}-{name}: {number(token['px'])}px;") - elif kind == "typography" and "sizes" in token: - for context, pixels in token["sizes"].items(): - lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})") - elif kind == "duration": - lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") - css.append(f" --{group_name}-{name}: {value};") - elif kind == "transition" and value.get("type") == "spring": - lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f") - lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f") - elif kind == "shadow": - for suffix in ("gapPx", "ringPx"): - lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp") - elif isinstance(token.get("dp"), (float, int)): - lines.append(f" val {name} = {number(token['dp'])}.dp") - css.append(f" --{group_name}-{name}: {number(token['px'])}px;") - elif isinstance(token.get("dp"), list): - for suffix, dimension in zip(("Width", "Height"), token["dp"]): - lines.append(f" val {name}{suffix} = {number(dimension)}.dp") - elif isinstance(token.get("dp"), dict): - for suffix, dimension in token["dp"].items(): - lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp") - if kind == "card": - lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}") - elif group_name == "image" and isinstance(token.get("px"), list): - for suffix, dimension in zip(("Width", "Height"), token["px"]): - lines.append(f" const val {name}{suffix} = {int(dimension)}") - elif group_name == "image" and isinstance(value, dict): - for suffix, count in value.items(): - lines.append(f" const val {name}{suffix[0].upper() + suffix[1:]} = {int(count)}") - elif isinstance(value, (float, int)) and not isinstance(value, bool): - lines.append(f" const val {name} = {number(value)}f") - elif kind in ("gradient", "filter", "rule") and isinstance(value, str): - lines.append(f" const val {name} = {json.dumps(value)}") - if kind == "gradient": - css.append(f" --{group_name}-{name}: {value};") - lines.append(" }") - lines.append("}") - css.append("}") - return "\n".join(lines) + "\n", "\n".join(css) + "\n" - - -def main() -> None: - """Write generated artifacts, or fail when committed artifacts drift.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--check", action="store_true") - arguments = parser.parse_args() - tokens = json.loads((ROOT / "tokens.json").read_text()) - tokens.update(json.loads((ROOT / "shell-tokens.json").read_text())) - tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) - tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) - tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) - kotlin, css = render_tokens(tokens) - outputs = {str(KOTLIN): kotlin, "tokens.css": css} - inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"] - checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} - checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) - outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) - for name, content in outputs.items(): - path = ROOT / name - if arguments.check: - if not path.exists() or path.read_text() != content: - raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})") - else: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest") - - -if __name__ == "__main__": - main() diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json deleted file mode 100644 index a574a31d..00000000 --- a/wolfskin-design/home-tokens.json +++ /dev/null @@ -1,322 +0,0 @@ -{ - "home": { - "kickerGap": { - "$type": "dimension", - "px": 12, - "dp": 6.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "kickerBottom": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:101" - }, - "titleBottom": { - "$type": "dimension", - "px": 16, - "dp": 8.0, - "source": "locked index.html:103" - }, - "factsHeight": { - "$type": "dimension", - "px": 38, - "dp": 19.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "factsBottom": { - "$type": "dimension", - "px": 14, - "dp": 7.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "factsGap": { - "$type": "dimension", - "px": 22, - "dp": 11.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "overviewBottom": { - "$type": "dimension", - "px": 30, - "dp": 15.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardPadding": { - "$type": "dimension", - "px": 22, - "dp": 11.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardOverlayTop": { - "$type": "dimension", - "px": 54, - "dp": 27.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardOverlayBottom": { - "$type": "dimension", - "px": 18, - "dp": 9.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "sectionTop": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "sectionAvatar": { - "$type": "dimension", - "px": 56, - "dp": 28.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "sectionGap": { - "$type": "dimension", - "px": 22, - "dp": 11.0, - "source": "locked index.html:180" - }, - "stateTextBottom": { - "$type": "dimension", - "px": 24, - "dp": 12.0, - "source": "locked index.html:198" - }, - "stateTop": { - "$type": "dimension", - "px": 20, - "dp": 10.0, - "source": "locked index.html:194" - }, - "statePaddingX": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "statePaddingY": { - "$type": "dimension", - "px": 34, - "dp": 17.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "stateGap": { - "$type": "dimension", - "px": 8, - "dp": 4.0, - "source": "locked index.html:195" - }, - "stateIcon": { - "$type": "dimension", - "px": 36, - "dp": 18.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "captionWidth": { - "$type": "dimension", - "px": 640, - "dp": 320.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "plateTop": { - "$type": "dimension", - "px": 14, - "dp": 7.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "plateSide": { - "$type": "dimension", - "px": 16, - "dp": 8.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "plateFade": { - "$type": "dimension", - "px": 46, - "dp": 23.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "ratingGap": { - "$type": "dimension", - "px": 20, - "dp": 10.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "ratingMarkGap": { - "$type": "dimension", - "px": 8, - "dp": 4.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonWidth": { - "$type": "dimension", - "px": 640, - "dp": 320.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonHeight": { - "$type": "dimension", - "px": 84, - "dp": 42.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonFactsWidth": { - "$type": "dimension", - "px": 460, - "dp": 230.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonFactsHeight": { - "$type": "dimension", - "px": 34, - "dp": 17.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonButtonWidth": { - "$type": "dimension", - "px": 260, - "dp": 130.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonGap": { - "$type": "dimension", - "px": 22, - "dp": 11.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSkeletonButtonGap": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "artGlyph": { - "$type": "dimension", - "px": 44, - "dp": 22.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardSheetLeft": { - "$type": "dimension", - "px": 22, - "dp": 11.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardSheetTop": { - "$type": "dimension", - "px": 18, - "dp": 9.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardSheetInset": { - "$type": "dimension", - "px": 36, - "dp": 18.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSheetRight": { - "$type": "dimension", - "px": 180, - "dp": 90.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroSheetTop": { - "$type": "dimension", - "px": 150, - "dp": 75.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "cardBlur": { - "$type": "dimension", - "px": 26, - "dp": 13.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "heroBlur": { - "$type": "dimension", - "px": 60, - "dp": 30.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "progressInset": { - "$type": "dimension", - "px": 18, - "dp": 9.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "progressBottom": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "endTop": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "frameBottom": { - "$type": "dimension", - "px": 80, - "dp": 40.0, - "source": "locked index.html:1042" - }, - "toastPadding": { - "$type": "dimension", - "px": 28, - "dp": 14.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "toastTop": { - "$type": "dimension", - "px": 150, - "dp": 75.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "toastWidth": { - "$type": "dimension", - "px": 760, - "dp": 380.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "toastPaddingY": { "$type": "dimension", "px": 22, "dp": 11, "source": "locked index.html:270" }, - "toastGap": { "$type": "dimension", "px": 14, "dp": 7, "source": "locked index.html:270" }, - "toastShadowBlur": { "$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:270" }, - "toastShadowY": { "$type": "dimension", "px": 20, "dp": 10, "source": "locked index.html:270" }, - "toastShadowOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:270" }, - "cardWashInset": { - "$type": "dimension", - "px": 30, - "dp": 15.0, - "source": "locked index.html:129" - }, - "heroWashInset": { - "$type": "dimension", - "px": 80, - "dp": 40.0, - "source": "locked index.html:96" - }, - "noteTop": { - "$type": "dimension", - "px": 14, - "dp": 7.0, - "source": "locked index.html:191" - }, - "noteGap": { - "$type": "dimension", - "px": 16, - "dp": 8.0, - "source": "locked index.html:191" - }, - "hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" }, - "savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" }, - "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, - "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, - "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, - "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, - "posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" } - } -} diff --git a/wolfskin-design/request-marks.sha256 b/wolfskin-design/request-marks.sha256 deleted file mode 100644 index c29ddefc..00000000 --- a/wolfskin-design/request-marks.sha256 +++ /dev/null @@ -1,12 +0,0 @@ -01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg -743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png -27f93b15a075b015e2448eca7949f7a3da03c37fbf26cecf6df3bdab45fda993 request-marks/imdb.svg -a738afd57519b1b4a38192927b48bd45755ce75e4673c6b12b9b3e0c2e388372 src/main/res/drawable-nodpi/coming_in_rating_imdb.png -510d0f9fb77d72190ebf03208f04215a4e86f71e165488518ac75aa49cf4a79d request-marks/rotten.svg -0aade711ba57b6b201c7395ece97108df928b6076d8120404cdf4155a4242632 src/main/res/drawable-nodpi/coming_in_rating_rotten.png -b8dcf4d27eba2446380980c96d82b1f64aa6859fa2ea1cdab867f849f9fec4fd request-marks/spilled.svg -fa4f50258009cd1d16f0d0f5a3c216dc88a57409b1bccc379512ae914cc9d714 src/main/res/drawable-nodpi/coming_in_rating_spilled.png -d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b request-marks/tmdb.svg -8e1cb507f876f324bb5c53bf4e60775712f1fdddbffe94fd249e6e74099e045e src/main/res/drawable-nodpi/coming_in_rating_tmdb.png -2ad511c7a101a7f2f8810434911df8c13cc126fa691272242037237172600bc3 request-marks/upright.svg -6ff8a05501bc1c15b96c0130271c41baaa2ddb74f64916f58b9e7f918931d789 src/main/res/drawable-nodpi/coming_in_rating_upright.png diff --git a/wolfskin-design/request-marks/fresh.svg b/wolfskin-design/request-marks/fresh.svg deleted file mode 100644 index 977253a9..00000000 --- a/wolfskin-design/request-marks/fresh.svg +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> -<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> - <metadata id="metadata3396"> - <rdf:RDF> - <cc:Work rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> - <dc:title/> - </cc:Work> - </rdf:RDF> - </metadata> - <g id="layer1" fill="#f93208"> - <path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/> - <path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/> - </g> - <g id="layer2"> - <path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/> - </g> -</svg> diff --git a/wolfskin-design/request-marks/imdb.svg b/wolfskin-design/request-marks/imdb.svg deleted file mode 100644 index b2a908bc..00000000 --- a/wolfskin-design/request-marks/imdb.svg +++ /dev/null @@ -1,3 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> -<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg> \ No newline at end of file diff --git a/wolfskin-design/request-marks/rotten.svg b/wolfskin-design/request-marks/rotten.svg deleted file mode 100644 index 04bef05b..00000000 --- a/wolfskin-design/request-marks/rotten.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="145" height="140"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg> \ No newline at end of file diff --git a/wolfskin-design/request-marks/spilled.svg b/wolfskin-design/request-marks/spilled.svg deleted file mode 100644 index af36063b..00000000 --- a/wolfskin-design/request-marks/spilled.svg +++ /dev/null @@ -1,46 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> -<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> - <metadata id="metadata3886"> - <rdf:RDF> - <cc:Work rdf:about=""> - <dc:format>image/svg+xml</dc:format> - <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> - <dc:title/> - </cc:Work> - </rdf:RDF> - </metadata> - <g id="g3998" transform="matrix(.0042962 .96345 -.96345 .0042962 135.21 .16894)"></g> - <g id="layer4"> - <path id="path3462" d="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" fill="#07a23b"/> - <g fill="#fff"> - <path id="path3505" d="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"/> - <path id="path3551" d="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"/> - <path id="path3553" d="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"/> - <path id="path3555" d="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"/> - </g> - </g> - <g id="layer5"> - <path id="path4031" d="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" fill="#03621e"/> - </g> - <g id="layer6" fill="#fdd600"> - <path id="path4136" d="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"/> - <path id="path4174" d="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/> - <path id="path4176" d="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"/> - <path id="path4178" d="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"/> - <path id="path4180" d="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"/> - <path id="path4182" d="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/> - <path id="path4184" d="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"/> - <path id="path4186" d="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"/> - <path id="path4188" d="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"/> - <path id="path4190" d="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"/> - <path id="path4192" d="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/> - <path id="path4194" d="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/> - <path id="path4196" d="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"/> - <path id="path4198" d="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"/> - <path id="path4200" d="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"/> - </g> - <g id="layer7"> - <path id="path4203" d="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" fill="#09a339"/> - </g> -</svg> diff --git a/wolfskin-design/request-marks/tmdb.svg b/wolfskin-design/request-marks/tmdb.svg deleted file mode 100644 index 62a66055..00000000 --- a/wolfskin-design/request-marks/tmdb.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4 \ No newline at end of file diff --git a/wolfskin-design/request-marks/upright.svg b/wolfskin-design/request-marks/upright.svg deleted file mode 100644 index bf1cc85a..00000000 --- a/wolfskin-design/request-marks/upright.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json deleted file mode 100644 index e66ac6ec..00000000 --- a/wolfskin-design/shell-tokens.json +++ /dev/null @@ -1,433 +0,0 @@ -{ - "requests": { - "sheetPaddingY": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, - "sheetPaddingX": { "$type": "dimension", "px": 64, "dp": 32, "source": "index.html:227" }, - "sheetGap": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:227" }, - "sheetTitleLineHeight": { "$type": "dimension", "px": 56, "dp": 28, "source": "index.html:228; locked st-083/st-086: content begins at 154px after 80px padding and 18px gap" }, - "sheetTitleBaseline": { "$type": "dimension", "px": 44, "dp": 22, "source": "locked st-083: modal ink-bottom 123px, native baseline boundary 124px minus 80px padding" }, - "titleLineHeight": { "$type": "dimension", "px": 39, "dp": 19.5, "source": "index.html:250; 30px font with 1.3 line box" }, - "titleBaseline": { "$type": "dimension", "px": 31, "dp": 15.5, "source": "locked st-082/st-086: ink-bottom 45px, 15px text-block inset, baseline boundary 1px below ink" }, - "factsLineHeight": { "$type": "dimension", "px": 31.2, "dp": 15.6, "source": "index.html:251; 24px font with 1.3 line box" }, - "factsBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 77px, 15px block inset, 39px title box and 1px baseline boundary" }, - "stageLineHeight": { "$type": "dimension", "px": 39.6, "dp": 19.8, "source": "index.html:251-252; inline-flex stage within the 28px/1.3 parent strut" }, - "stageLabelLineHeight": { "$type": "dimension", "px": 33.8, "dp": 16.9, "source": "index.html:220; 26px font with 1.3 leading" }, - "stageBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 112px, 15px block inset, 39px title, 31.2px facts, 4px margin and 1px baseline boundary" }, - "emptyMargin": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:1905" }, - "rowPaddingY": { "$type": "dimension", "px": 14, "dp": 7, "source": "index.html:247" }, - "rowPaddingX": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:247" }, - "rowGap": { "$type": "dimension", "px": 24, "dp": 12, "source": "index.html:247" }, - "listGap": { "$type": "dimension", "px": 6, "dp": 3, "source": "index.html:255" }, - "stageMargin": { "$type": "dimension", "px": 4, "dp": 2, "source": "index.html:252" }, - "stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" }, - "stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" }, - "readinessGapX": { "$type": "dimension", "px": 22, "dp": 11, "source": "index.html:312" }, - "readinessGapY": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:312" }, - "readinessMargin": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:312" }, - "readinessGlyph": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:314" }, - "chevron": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:317" }, - "chevronAlpha": { "$type": "number", "$value": 0.6, "source": "index.html:317" }, - "receiptPaddingY": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:243" }, - "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, - "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, - "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, - "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, - "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, - "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, - "skeletonSubtitleWidth": { "$type": "dimension", "px": 240, "dp": 120, "source": "index.html:1902" }, - "skeletonSubtitleHeight": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:1902" }, - "skeletonGap": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:1902" }, - "ratingsGap": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:156" }, - "ratingsEntryGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:157" }, - "shadowBlur": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" }, - "shadowOffset": { "$type": "dimension", "px": -30, "dp": -15, "source": "index.html:227" }, - "shadowColor": { "$type": "color", "$value": "#00000080", "source": "index.html:227" }, - "ratingsWordmarkRatio": { "$type": "number", "$value": 1.8, "source": "index.html:160" } - }, - "shell": { - "navLabelFade": { - "$type": "duration", - "$value": "200ms", - "source": "index.html:81; nav label color transition uses 200 ms ease-out" - }, - "zero": { - "$type": "dimension", - "px": 0, - "dp": 0.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "stageWidth": { - "$type": "dimension", - "px": 1920, - "dp": 960.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "stageHeight": { - "$type": "dimension", - "px": 1080, - "dp": 540.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "wordmarkWidth": { - "$type": "dimension", - "px": 169, - "dp": 84.5, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "wordmarkTop": { - "$type": "dimension", - "px": 73.6, - "dp": 36.8, - "source": "index.html:74,79: centre a 46.8 px line in the 64 px nav item" - }, - "navHomeWidth": { - "$type": "dimension", - "px": 149, - "dp": 74.5, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "navDiscoverWidth": { - "$type": "dimension", - "px": 198, - "dp": 99.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "navMoviesWidth": { - "$type": "dimension", - "px": 171, - "dp": 85.5, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "navShowsWidth": { - "$type": "dimension", - "px": 161, - "dp": 80.5, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "navAnimeWidth": { - "$type": "dimension", - "px": 158, - "dp": 79.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "navItemTop": { - "$type": "dimension", - "px": 65, - "dp": 32.5, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerTitleTop": { - "$type": "dimension", - "px": 272.3, - "dp": 136.15, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerPeopleTop": { - "$type": "dimension", - "px": 425.5, - "dp": 212.75, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerNameGap": { - "$type": "dimension", - "px": 26, - "dp": 13.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerGearTop": { - "$type": "dimension", - "px": 751.7, - "dp": 375.85, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerGearCaptionTop": { - "$type": "dimension", - "px": 72, - "dp": 36.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerLetter": { - "$type": "dimension", - "px": 100, - "dp": 50.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerSkeletonNameWidth": { - "$type": "dimension", - "px": 140, - "dp": 70.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerSkeletonNameHeight": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerLoadingTitleTop": { - "$type": "dimension", - "px": 247.4, - "dp": 123.7, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerLoadingPeopleTop": { - "$type": "dimension", - "px": 400.6, - "dp": 200.3, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerLoadingButtonTop": { - "$type": "dimension", - "px": 756.6, - "dp": 378.3, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerErrorTitleTop": { - "$type": "dimension", - "px": 297.6, - "dp": 148.8, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerErrorPanelTop": { - "$type": "dimension", - "px": 450.8, - "dp": 225.4, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerErrorPanelWidth": { - "$type": "dimension", - "px": 980, - "dp": 490.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerErrorPanelHeight": { - "$type": "dimension", - "px": 185.6, - "dp": 92.8, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "pickerErrorButtonTop": { - "$type": "dimension", - "px": 706.4, - "dp": 353.2, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsHeaderTop": { - "$type": "dimension", - "px": 150, - "dp": 75.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsHeaderAvatar": { - "$type": "dimension", - "px": 52, - "dp": 26.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsHeaderGap": { - "$type": "dimension", - "px": 24, - "dp": 12.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsRowTop": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsRowPadding": { - "$type": "dimension", - "px": 30, - "dp": 15.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsRowGap": { - "$type": "dimension", - "px": 24, - "dp": 12.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsIcon": { - "$type": "dimension", - "px": 36, - "dp": 18.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsPillHeight": { - "$type": "dimension", - "px": 40, - "dp": 20.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsPillStroke": { - "$type": "dimension", - "px": 2, - "dp": 1.0, - "source": "index.html:122; pill border 2 px inside its 40 px border-box" - }, - "settingsPillDashRatio": { - "$type": "number", - "$value": 3, - "source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width" - }, - "settingsPillDashGapRatio": { - "$type": "number", - "$value": 2, - "source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width" - }, - "settingsPillBorder": { - "$type": "color", - "$value": "#ffffff38", - "source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha" - }, - "settingsPillFocusedBorder": { - "$type": "color", - "$value": "#0b0b1066", - "source": "index.html:328; focused pill ink at 40 percent opacity" - }, - "settingsPillPadding": { - "$type": "dimension", - "px": 16, - "dp": 8.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsPillGap": { - "$type": "dimension", - "px": 12, - "dp": 6.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsChevron": { - "$type": "dimension", - "px": 30, - "dp": 15.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "settingsEndPadding": { - "$type": "dimension", - "px": 620, - "dp": 310.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "panelPaddingX": { - "$type": "dimension", - "px": 30, - "dp": 15.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "panelPaddingY": { - "$type": "dimension", - "px": 28, - "dp": 14.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "panelGap": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "panelHeadingGap": { - "$type": "dimension", - "px": 14, - "dp": 7.0, - "source": "index.html:244: receipt heading icon-to-title gap" - }, - "panelIcon": { - "$type": "dimension", - "px": 36, - "dp": 18.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "skeletonRadius": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" - }, - "gearOpacity": { - "$type": "number", - "$value": 0.7, - "source": "locked index.html CSS / measured st-* bounds" - }, - "navBandOpacity": { - "$type": "number", - "$value": 0.72, - "source": "locked index.html CSS / measured st-* bounds" - }, - "navSolidStop": { - "$type": "number", - "$value": 0.84, - "source": "locked index.html CSS / measured st-* bounds" - } - }, - "library": { - "chipHeight": {"$type":"dimension","px":68,"dp":34,"source":"index.html:185"}, - "chipPadding": {"$type":"dimension","px":28,"dp":14,"source":"index.html:185"}, - "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, - "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, - "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, - "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, - "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, - "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, - "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, - "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, - "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, - "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, - "plateInlineDescent": {"$type":"dimension","px":6.6,"dp":3.3,"source":"index.html:156,162 inline-flex baseline strut; proto-104 rplate 100.2px minus 14+33.6+46"}, - "plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"}, - "plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"}, - "plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"}, - "markGapEm": {"$type":"number","$value":0.29,"source":"index.html:165"}, - "wordmarkRatio": {"$type":"number","$value":1.8,"source":"index.html:160"}, - "plateOpacity": {"$type":"number","$value":0.9,"source":"index.html:162"}, - "plateMidOpacity": {"$type":"number","$value":0.72,"source":"index.html:162"}, - "plateMidStop": {"$type":"number","$value":0.52,"source":"index.html:162"}, - "railWidth": {"$type":"dimension","px":52,"dp":26,"source":"index.html:204"}, - "railLetterHeight": {"$type":"dimension","px":30,"dp":15,"source":"index.html:205"}, - "railFocusHeight": {"$type":"dimension","px":44,"dp":22,"source":"index.html:208"}, - "railLetter": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:205; prototype wins over spec 4.3 caption weight"}, - "railFocusedLetter": {"$type":"typography","px":28,"sp":14,"weight":700,"$value":{"fontWeight":700},"source":"index.html:208; prototype wins over spec 4.3 caption size"}, - "panelPaddingX": {"$type":"dimension","px":40,"dp":20,"source":"index.html:194"}, - "panelPaddingY": {"$type":"dimension","px":34,"dp":17,"source":"index.html:194"}, - "panelTextGap": {"$type":"dimension","px":24,"dp":12,"source":"index.html:198"}, - "panelTitleGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:195"}, - "panelIconGap": {"$type":"dimension","px":14,"dp":7,"source":"index.html:195"}, - "panelTop": {"$type":"dimension","px":20,"dp":10,"source":"index.html:194"}, - "borderWidth": {"$type":"dimension","px":2,"dp":1,"source":"index.html:188,194,299"}, - "chipContentGap": {"$type":"dimension","px":12,"dp":6,"source":"index.html:185"}, - "chipLabelOpacity": {"$type":"number","$value":0.7,"source":"index.html:190"}, - "tailIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:300"}, - "tailPadding": {"$type":"dimension","px":20,"dp":10,"source":"index.html:299"}, - "tailGap": {"$type":"dimension","px":10,"dp":5,"source":"index.html:299"}, - "badgeRing": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"}, - "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, - "seeAllGap": {"$type":"dimension","px":8,"dp":4,"source":"index.html:146"}, - "tailDescription": {"$type":"typography","px":24,"sp":12,"weight":500,"$value":{"fontWeight":500},"source":"index.html:301"}, - "headerSubtitle": {"$type":"typography","px":30,"sp":15,"weight":400,"$value":{"fontWeight":400},"source":"index.html:201"}, - "imageReads": {"$type":"number","$value":4,"source":"spec 4.9, tokens.json image.concurrentReads"}, - "unknownCriticOpacity": {"$type":"number","$value":0.55,"source":"spec 4.7, index.html:161"}, - "fallbackPadding": {"$type":"dimension","px":22,"dp":11,"source":"index.html:130"}, - "fallbackIcon": {"$type":"dimension","px":44,"dp":22,"source":"index.html:130"}, - "fallbackHueMultiplier": {"$type":"number","$value":31,"source":"index.html:705"}, - "fallbackSaturationTop": {"$type":"number","$value":0.22,"source":"index.html:130"}, - "fallbackLightnessTop": {"$type":"number","$value":0.22,"source":"index.html:130"}, - "fallbackSaturationBottom": {"$type":"number","$value":0.26,"source":"index.html:130"}, - "fallbackLightnessBottom": {"$type":"number","$value":0.1,"source":"index.html:130"}, - "fallbackIconOpacity": {"$type":"number","$value":0.45,"source":"index.html:132"} - }, - "lift": { - "cardBlur": {"$type":"dimension","px":50,"dp":25,"source":"index.html:134; spec 4.5"}, - "cardY": {"$type":"dimension","px":18,"dp":9,"source":"index.html:134"}, - "cardOpacity": {"$type":"number","$value":0.65,"source":"index.html:134"}, - "buttonBlur": {"$type":"dimension","px":40,"dp":20,"source":"index.html:110; spec 4.5"}, - "buttonY": {"$type":"dimension","px":12,"dp":6,"source":"spec 4.5"}, - "buttonOpacity": {"$type":"number","$value":0.5,"source":"spec 4.5"}, - "heroBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:97"}, - "heroY": {"$type":"dimension","px":30,"dp":15,"source":"index.html:97"}, - "heroOpacity": {"$type":"number","$value":0.6,"source":"index.html:97"}, - "sheetBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:227"}, - "sheetX": {"$type":"dimension","px":-30,"dp":-15,"source":"index.html:227"}, - "sheetOpacity": {"$type":"number","$value":0.5,"source":"index.html:227"} - } -} diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg deleted file mode 100644 index f5b2f94c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-announcement-2022-s1e7-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg deleted file mode 100644 index f2519b04..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg deleted file mode 100644 index 77febd79..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-1-2025-s1e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-episode-2-2016-s1e2-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-erickerhead-2026-s9e6-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-girls-interrupted-2007-s1e7-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-i-heart-ny-2002-s4e18-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-new-day-2021-s1e2-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg deleted file mode 100644 index 9d95ba77..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-no-honeymoon-2020-s1e8-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg deleted file mode 100644 index ecb7e8cd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-notapusy-2005-s3e4-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-one-of-us-2024-s1e7-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg deleted file mode 100644 index 23a242da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-remembrance-day-2026-s2e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-sex-and-the-country-2001-s4e9-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg deleted file mode 100644 index cd0aec39..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg deleted file mode 100644 index 84944d39..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg deleted file mode 100644 index bc22e11a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-the-uncomfortability-2010-s1e2-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg deleted file mode 100644 index ba65ca2d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-three-monsters-1992-s1e6-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-tomorrow-2024-s3e1-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg deleted file mode 100644 index 13f0f7b3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg deleted file mode 100644 index f0d6f752..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg deleted file mode 100644 index b9b72894..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg deleted file mode 100644 index d93357e5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg deleted file mode 100644 index 15c11ff7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg deleted file mode 100644 index f7444271..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-bronx-tale-1993-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg deleted file mode 100644 index b386b136..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg deleted file mode 100644 index 580b22f4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-a-silent-voice-the-movie-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg deleted file mode 100644 index eb28037c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg deleted file mode 100644 index 3ce49112..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-absolution-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg deleted file mode 100644 index 6c1ee8ac..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg deleted file mode 100644 index 52362b4e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-across-the-hall-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg deleted file mode 100644 index 5c016976..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg deleted file mode 100644 index e9a03c14..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-addicted-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg deleted file mode 100644 index 331391ed..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg deleted file mode 100644 index 8b65745b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-adulterers-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg deleted file mode 100644 index ac4b828a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg deleted file mode 100644 index 8beb2f7f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-all-the-money-in-the-world-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg deleted file mode 100644 index ddf8f1dc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg deleted file mode 100644 index 7ce6fa96..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-alone-with-her-2006-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg deleted file mode 100644 index cb5c5de7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg deleted file mode 100644 index 0f41d299..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-american-gangster-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg deleted file mode 100644 index 16d0b27d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg deleted file mode 100644 index 9023a691..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-annie-hall-1977-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg deleted file mode 100644 index 2a99846d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg deleted file mode 100644 index 030addf0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg deleted file mode 100644 index 93292da3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg deleted file mode 100644 index 09cd6aa8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-apocalypse-now-1979-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg deleted file mode 100644 index 82579b86..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg deleted file mode 100644 index 62bd0afa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-appleseed-alpha-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg deleted file mode 100644 index 6693cb6d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg deleted file mode 100644 index 4550bdaa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg deleted file mode 100644 index 1794c99d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg deleted file mode 100644 index 605724e7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg deleted file mode 100644 index fe224a15..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-arrival-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg deleted file mode 100644 index d3b12fe0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg deleted file mode 100644 index f57f1052..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg deleted file mode 100644 index f739d620..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg deleted file mode 100644 index 8163931a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg deleted file mode 100644 index b096fff2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg deleted file mode 100644 index 90dc4672..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg deleted file mode 100644 index 57df9298..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-avengers-infinity-war-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg deleted file mode 100644 index 6ddd7f59..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg deleted file mode 100644 index 9b538d9f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg deleted file mode 100644 index 7f59127b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-ninja-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg deleted file mode 100644 index 9d385801..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg deleted file mode 100644 index ce5623fe..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg deleted file mode 100644 index b4fa63b9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg deleted file mode 100644 index f8115170..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg deleted file mode 100644 index 3232fb22..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg deleted file mode 100644 index dde008b5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-adam-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg deleted file mode 100644 index 4b0b262f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg deleted file mode 100644 index 61c8860f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-bear-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg deleted file mode 100644 index f0b8de8a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg deleted file mode 100644 index 6fdaa3d2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg deleted file mode 100644 index ac7ad532..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg deleted file mode 100644 index d0219a75..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-black-panther-wakanda-forever-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg deleted file mode 100644 index f39cf5ce..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg deleted file mode 100644 index 8d50255f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-blame-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg deleted file mode 100644 index f426d0ce..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg deleted file mode 100644 index d3619fbd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg deleted file mode 100644 index b6b5a7cd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg deleted file mode 100644 index 20d2f9ee..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-bohemian-rhapsody-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg deleted file mode 100644 index c1c84c59..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg deleted file mode 100644 index 32fd8d4d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-braveheart-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg deleted file mode 100644 index e49fe1d5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg deleted file mode 100644 index b54cc5c7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-butterfly-on-a-wheel-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg deleted file mode 100644 index e536359f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg deleted file mode 100644 index 92fd601f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-by-any-means-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg deleted file mode 100644 index 5700c4e7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg deleted file mode 100644 index 426142ef..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-civil-war-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg deleted file mode 100644 index 411ceebe..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg deleted file mode 100644 index bba96574..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-first-avenger-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg deleted file mode 100644 index 5661fcff..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg deleted file mode 100644 index c2dc5612..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg deleted file mode 100644 index 988e20d5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg deleted file mode 100644 index b58ce600..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-careful-what-you-wish-for-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg deleted file mode 100644 index f785c549..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg deleted file mode 100644 index a6a99fee..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-castle-in-the-sky-1986-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg deleted file mode 100644 index 6b3d02a3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg deleted file mode 100644 index 1def9414..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-chicago-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg deleted file mode 100644 index d41a10ef..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg deleted file mode 100644 index f26414a7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-citizen-kane-1941-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg deleted file mode 100644 index 37da64f9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg deleted file mode 100644 index f3601b9b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-coherence-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg deleted file mode 100644 index db0a7fcd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg deleted file mode 100644 index c6aecefa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-conclave-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg deleted file mode 100644 index 6c70a452..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg deleted file mode 100644 index 4acfa871..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-crime-of-passion-1956-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg deleted file mode 100644 index bec98992..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg deleted file mode 100644 index 018d4f4b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-deep-water-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg deleted file mode 100644 index e958449b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg deleted file mode 100644 index d93f3124..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-derailed-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg deleted file mode 100644 index 754e713f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg deleted file mode 100644 index 29ecc103..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg deleted file mode 100644 index d3444637..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-house-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg deleted file mode 100644 index 7f5a31ac..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg deleted file mode 100644 index 3b563cf9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-dream-lover-1994-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg deleted file mode 100644 index ee642530..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg deleted file mode 100644 index 4cf7d66b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-drop-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg deleted file mode 100644 index 9096a0fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg deleted file mode 100644 index 01a26f3b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-eastern-promises-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg deleted file mode 100644 index c27fa57b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg deleted file mode 100644 index 45e13a32..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-easy-a-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg deleted file mode 100644 index 8edc02a6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg deleted file mode 100644 index 7a62df45..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg deleted file mode 100644 index ac0777c7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg deleted file mode 100644 index c1bee971..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-emancipation-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg deleted file mode 100644 index 60273dd9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg deleted file mode 100644 index 43f996bd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-faces-1968-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg deleted file mode 100644 index b13c33c6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg deleted file mode 100644 index dda9d622..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fatale-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg deleted file mode 100644 index 46835996..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg deleted file mode 100644 index 169c466d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-fighting-with-my-family-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg deleted file mode 100644 index 3735ee63..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg deleted file mode 100644 index 93ba0d71..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg deleted file mode 100644 index a7e5ddff..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg deleted file mode 100644 index e69af7c0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-free-state-of-jones-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg deleted file mode 100644 index 32a28bd7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg deleted file mode 100644 index 264076e2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-from-up-on-poppy-hill-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg deleted file mode 100644 index 716b8a87..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg deleted file mode 100644 index 58d221ba..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg deleted file mode 100644 index 4a9931ed..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg deleted file mode 100644 index ae262147..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg deleted file mode 100644 index 42c4cea6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg deleted file mode 100644 index 6a6aa651..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-gosford-park-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg deleted file mode 100644 index 0e1add06..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg deleted file mode 100644 index 1b71996a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-grave-of-the-fireflies-1988-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg deleted file mode 100644 index 0aeef2ba..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg deleted file mode 100644 index 042a6c80..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-howl-s-moving-castle-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg deleted file mode 100644 index 7cd4ee24..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg deleted file mode 100644 index e05bfdb0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-in-the-heights-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg deleted file mode 100644 index 032771a0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg deleted file mode 100644 index 41cc1eb5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-jennifer-s-body-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg deleted file mode 100644 index 2a108f0c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg deleted file mode 100644 index 80a59b1e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-kiki-s-delivery-service-1989-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg deleted file mode 100644 index 38203b77..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg deleted file mode 100644 index 4c78fee9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg deleted file mode 100644 index a24e64ab..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg deleted file mode 100644 index 94ebe97d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-locke-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg deleted file mode 100644 index a064686f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg deleted file mode 100644 index 423043cf..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-look-who-s-back-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg deleted file mode 100644 index 2fcaeaad..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg deleted file mode 100644 index f3725b42..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-memories-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg deleted file mode 100644 index 45591cc8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg deleted file mode 100644 index cfc0d812..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-mercy-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg deleted file mode 100644 index da38be48..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg deleted file mode 100644 index c6fa6a26..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg deleted file mode 100644 index 51711864..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg deleted file mode 100644 index f4c89db7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-normal-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg deleted file mode 100644 index 54ac5a2a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg deleted file mode 100644 index d6760468..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-outlaw-king-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg deleted file mode 100644 index ec877751..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg deleted file mode 100644 index 0b2a0d30..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-panic-room-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg deleted file mode 100644 index e9d7155a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg deleted file mode 100644 index a4814472..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg deleted file mode 100644 index 8aa4ecbd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg deleted file mode 100644 index f548e831..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-ponyo-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg deleted file mode 100644 index 63d8112e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg deleted file mode 100644 index 4a6a2bd1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-porco-rosso-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg deleted file mode 100644 index 9486d2cf..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg deleted file mode 100644 index 21dd9359..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-princess-mononoke-1997-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg deleted file mode 100644 index 724008c5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg deleted file mode 100644 index 411b149b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-prisoners-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg deleted file mode 100644 index 1d332cec..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg deleted file mode 100644 index cc2ef2ce..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-racing-stripes-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg deleted file mode 100644 index 326e8ebc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg deleted file mode 100644 index e9ab449c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-reincarnated-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg deleted file mode 100644 index 21842ef2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg deleted file mode 100644 index 6f89abc3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-requiem-for-a-dream-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg deleted file mode 100644 index ff0c14c0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg deleted file mode 100644 index c79e70a1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg deleted file mode 100644 index 7ea43937..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg deleted file mode 100644 index 33408fa0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-secondhand-lions-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg deleted file mode 100644 index 3cc0d123..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg deleted file mode 100644 index 0f6017ed..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-self-reliance-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg deleted file mode 100644 index c9649485..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg deleted file mode 100644 index 8ddd6e52..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-serpico-1973-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg deleted file mode 100644 index 13f14f93..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg deleted file mode 100644 index 6b2b79ce..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shaft-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg deleted file mode 100644 index 6e98e242..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg deleted file mode 100644 index f200a28d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg deleted file mode 100644 index d04685fe..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg deleted file mode 100644 index 428bb38b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg deleted file mode 100644 index df5bb046..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg deleted file mode 100644 index a5ffff1f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-sleeping-with-other-people-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg deleted file mode 100644 index c1281a9c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg deleted file mode 100644 index be1873fd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-snowpiercer-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg deleted file mode 100644 index 2fdaadb4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg deleted file mode 100644 index 51fa155f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-brand-new-day-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg deleted file mode 100644 index d09f655a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg deleted file mode 100644 index 28521997..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-far-from-home-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg deleted file mode 100644 index 6088fadf..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg deleted file mode 100644 index 97fc9c05..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-homecoming-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg deleted file mode 100644 index a76afec9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg deleted file mode 100644 index bc92a4cd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spider-man-no-way-home-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg deleted file mode 100644 index af91c123..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg deleted file mode 100644 index 5a79da62..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-spirited-away-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg deleted file mode 100644 index f6675060..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg deleted file mode 100644 index e85ddb90..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg deleted file mode 100644 index 7946ea40..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg deleted file mode 100644 index c410b981..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-taxi-driver-1976-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg deleted file mode 100644 index ea619c14..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg deleted file mode 100644 index bb009cbf..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-alto-knights-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg deleted file mode 100644 index 7c5878b7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg deleted file mode 100644 index 4de2711f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg deleted file mode 100644 index 75ae9b64..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg deleted file mode 100644 index c1aec876..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-amazing-spider-man-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg deleted file mode 100644 index 7d67ccb0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg deleted file mode 100644 index 65553d47..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-animatrix-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg deleted file mode 100644 index 83636f25..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg deleted file mode 100644 index be87067c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-avengers-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg deleted file mode 100644 index a19dc77b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg deleted file mode 100644 index 094df64e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-boy-and-the-heron-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg deleted file mode 100644 index 9f979cd8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg deleted file mode 100644 index d1823120..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-breakfast-club-1985-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg deleted file mode 100644 index 2f016498..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg deleted file mode 100644 index a0379f2f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-dry-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg deleted file mode 100644 index 6e920467..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg deleted file mode 100644 index 8fbb8f2a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg deleted file mode 100644 index 94111d9b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-1972-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg deleted file mode 100644 index 6dbff534..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg deleted file mode 100644 index 1328c99e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-ii-1974-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg deleted file mode 100644 index d2080487..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg deleted file mode 100644 index abbe5e99..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-godfather-part-iii-1990-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg deleted file mode 100644 index 7a4f8a32..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg deleted file mode 100644 index e949fb3e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-great-gatsby-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg deleted file mode 100644 index 507a78c4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg deleted file mode 100644 index f87e919f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-hours-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg deleted file mode 100644 index dd755ee8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg deleted file mode 100644 index 83464d4c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg deleted file mode 100644 index 3256d38d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg deleted file mode 100644 index 950e9ba1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-of-the-mohicans-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg deleted file mode 100644 index 509af2d6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg deleted file mode 100644 index 0d58440c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-last-samurai-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg deleted file mode 100644 index 862c064a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg deleted file mode 100644 index 15f26533..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-lighthouse-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg deleted file mode 100644 index c3bf8f52..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg deleted file mode 100644 index c292dce5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-1999-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg deleted file mode 100644 index dcbf7b96..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg deleted file mode 100644 index ab11f50f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-reloaded-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg deleted file mode 100644 index 99d85a40..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg deleted file mode 100644 index 4477ef1e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-resurrections-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg deleted file mode 100644 index 2cc02692..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg deleted file mode 100644 index 7236cc4e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-matrix-revolutions-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg deleted file mode 100644 index ce7ce498..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg deleted file mode 100644 index c8582133..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-patriot-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg deleted file mode 100644 index 698f4ad2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg deleted file mode 100644 index fe50c2f8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg deleted file mode 100644 index 47a8b372..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg deleted file mode 100644 index 72accfed..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-substance-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg deleted file mode 100644 index b9157b91..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg deleted file mode 100644 index 882621cb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg deleted file mode 100644 index 638eab3d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg deleted file mode 100644 index 21976395..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-uprising-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg deleted file mode 100644 index bb48460b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg deleted file mode 100644 index 0894754d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wild-robot-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg deleted file mode 100644 index 2af40e28..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg deleted file mode 100644 index b7688c77..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-wind-rises-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg deleted file mode 100644 index cb5bfb28..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg deleted file mode 100644 index 6db33db1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg deleted file mode 100644 index aad8ea2c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg deleted file mode 100644 index 8a54bc7f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg deleted file mode 100644 index a35327a9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg deleted file mode 100644 index 5a4ff0ab..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-thelma-louise-1991-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg deleted file mode 100644 index 7c9b445d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg deleted file mode 100644 index aee092c6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-triangle-of-sadness-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg deleted file mode 100644 index e119de53..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg deleted file mode 100644 index 362ddd82..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-tsotsi-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg deleted file mode 100644 index f456c01d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg deleted file mode 100644 index e32685f3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg deleted file mode 100644 index 2918db3a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg deleted file mode 100644 index 76855126..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vice-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg deleted file mode 100644 index 20eaa159..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg deleted file mode 100644 index 1351cad1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-vicky-cristina-barcelona-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg deleted file mode 100644 index 1fb22b96..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg deleted file mode 100644 index 918e424f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-warriors-of-the-wind-1984-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg deleted file mode 100644 index 4a24720d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg deleted file mode 100644 index a0d65904..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-we-bury-the-dead-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg deleted file mode 100644 index 2f186030..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg deleted file mode 100644 index 3a123e80..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg deleted file mode 100644 index 3d2825f5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg deleted file mode 100644 index 96e3596e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-12-monkeys-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg deleted file mode 100644 index dcda667a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg deleted file mode 100644 index 64ca587b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-56-days-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg deleted file mode 100644 index 24f66f2d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg deleted file mode 100644 index 3362da59..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg deleted file mode 100644 index b18891fd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg deleted file mode 100644 index b4d256eb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg deleted file mode 100644 index ce12fc71..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg deleted file mode 100644 index 79f1351c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg deleted file mode 100644 index 2df9722d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg deleted file mode 100644 index afd371a9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-absolutely-fabulous-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg deleted file mode 100644 index 1f5f6bdc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg deleted file mode 100644 index 207b1dd1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accel-world-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg deleted file mode 100644 index d41c6397..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg deleted file mode 100644 index 74af010a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-accused-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg deleted file mode 100644 index 9cc0ffe2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg deleted file mode 100644 index 53a7ba33..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-adults-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg deleted file mode 100644 index eaf9a4fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg deleted file mode 100644 index 4fae4e33..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-after-life-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg deleted file mode 100644 index 0c52f2e6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg deleted file mode 100644 index ea7dda99..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-agatha-christie-s-marple-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg deleted file mode 100644 index 7343fb32..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg deleted file mode 100644 index a29d6cb4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ahsoka-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg deleted file mode 100644 index 09fbb9dc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg deleted file mode 100644 index 09ccfc33..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-american-homecoming-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg deleted file mode 100644 index 909ae1ba..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg deleted file mode 100644 index 5b1666e6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-and-small-1978-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg deleted file mode 100644 index d2bcc88d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg deleted file mode 100644 index b506f990..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-all-creatures-great-small-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg deleted file mode 100644 index 84944d39..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-crime-story-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg deleted file mode 100644 index 75a1948b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg deleted file mode 100644 index 1f9e239a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-dad-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg deleted file mode 100644 index 4817c690..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg deleted file mode 100644 index c02c46da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-gods-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg deleted file mode 100644 index bf758ae0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg deleted file mode 100644 index 5f92bf05..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-housewife-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg deleted file mode 100644 index c3bfe5e1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg deleted file mode 100644 index 7f10e6aa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-american-primeval-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg deleted file mode 100644 index 59c981e5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg deleted file mode 100644 index 9904c74b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-and-just-like-that-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg deleted file mode 100644 index a874a0c0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andor-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg deleted file mode 100644 index 199249d7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg deleted file mode 100644 index 67b766b0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-andromeda-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg deleted file mode 100644 index 15e325c9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg deleted file mode 100644 index d0e37959..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg deleted file mode 100644 index e0c97321..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-aqua-teen-hunger-force-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg deleted file mode 100644 index 9300e20f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg deleted file mode 100644 index 6a8f9a24..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg deleted file mode 100644 index ecb7e8cd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrested-development-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg deleted file mode 100644 index b32f5aca..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg deleted file mode 100644 index adee3bb7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-arrow-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg deleted file mode 100644 index 39ecc0d6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg deleted file mode 100644 index 5cd5f098..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-atlantis-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg deleted file mode 100644 index dacfd59e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg deleted file mode 100644 index 61f459c6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-seven-havens-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg deleted file mode 100644 index f60120da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg deleted file mode 100644 index 6f99263a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg deleted file mode 100644 index ad740218..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg deleted file mode 100644 index 9d46c2ff..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-avatar-the-last-airbender-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg deleted file mode 100644 index 1ca5e56c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg deleted file mode 100644 index 8da6e8e2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-awkward-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg deleted file mode 100644 index 57ece15e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg deleted file mode 100644 index a12ac33e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-b-b-vol-liefde-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg deleted file mode 100644 index 7ffef681..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg deleted file mode 100644 index b00730a8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-back-to-life-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg deleted file mode 100644 index c4685e84..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg deleted file mode 100644 index f1c18f69..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ballers-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg deleted file mode 100644 index 4d61dfbe..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg deleted file mode 100644 index ceef0c9d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-baretta-1975-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg deleted file mode 100644 index b59c0e58..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg deleted file mode 100644 index 386d3d90..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-based-on-a-true-story-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg deleted file mode 100644 index 69e567cb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg deleted file mode 100644 index a864f50c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg deleted file mode 100644 index d04a1a20..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg deleted file mode 100644 index 7ca86592..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-batwoman-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg deleted file mode 100644 index e6c36ddd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg deleted file mode 100644 index fe11122d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-beat-bobby-flay-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg deleted file mode 100644 index 32bd63a9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg deleted file mode 100644 index fe264c76..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-benson-1979-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg deleted file mode 100644 index c7c91e2d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg deleted file mode 100644 index 93e1e86a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-boys-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg deleted file mode 100644 index 381774e7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg deleted file mode 100644 index ef024946..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-city-greens-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg deleted file mode 100644 index 760e0262..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg deleted file mode 100644 index 4f535fec..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-big-mouth-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg deleted file mode 100644 index 967fb621..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg deleted file mode 100644 index 97c885ad..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-ish-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg deleted file mode 100644 index 3a0854c9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg deleted file mode 100644 index 733f5cbc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-lightning-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg deleted file mode 100644 index 48df9d46..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg deleted file mode 100644 index 3ff9aeed..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-black-rabbit-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg deleted file mode 100644 index ef8eaaa3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg deleted file mode 100644 index b233e033..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-bleach-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg deleted file mode 100644 index 8175e568..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg deleted file mode 100644 index 5e193ab2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-blue-planet-ii-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg deleted file mode 100644 index 86d3d806..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg deleted file mode 100644 index 73b77df2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-brassic-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg deleted file mode 100644 index e4ac4ae3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg deleted file mode 100644 index c4f7217c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breaking-bad-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg deleted file mode 100644 index 9d95ba77..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-breeders-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg deleted file mode 100644 index ffff979b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-californication-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg deleted file mode 100644 index 1ee28b8d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg deleted file mode 100644 index 466a1a7b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-call-the-midwife-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg deleted file mode 100644 index f1fbee3e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg deleted file mode 100644 index a3779772..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chainsaw-man-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg deleted file mode 100644 index 818a6665..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg deleted file mode 100644 index 7664f4b0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-chernobyl-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg deleted file mode 100644 index 8deb8d5c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg deleted file mode 100644 index e75072ae..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-community-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg deleted file mode 100644 index 2920cf5a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg deleted file mode 100644 index 8d338532..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-cyberpunk-edgerunners-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg deleted file mode 100644 index 0cbc4b07..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg deleted file mode 100644 index f42cec16..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dan-da-dan-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg deleted file mode 100644 index 3525c2fc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg deleted file mode 100644 index f5546d88..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dead-end-job-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg deleted file mode 100644 index 76fae98c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg deleted file mode 100644 index 56a8cd79..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-derry-girls-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg deleted file mode 100644 index 40ec3001..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg deleted file mode 100644 index 0c4c38bc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-desperate-housewives-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg deleted file mode 100644 index 8d9f2727..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg deleted file mode 100644 index ac0b5900..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dopesick-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg deleted file mode 100644 index 5ac396b6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg deleted file mode 100644 index 0783ed8c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dr-stone-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg deleted file mode 100644 index 5b940b9f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg deleted file mode 100644 index eef2a4f3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-dune-prophecy-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg deleted file mode 100644 index 89dfafbf..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg deleted file mode 100644 index 6f06f2c9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-east-of-eden-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg deleted file mode 100644 index a8e9576c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg deleted file mode 100644 index f078bc89..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-etoile-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg deleted file mode 100644 index c9d6a190..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg deleted file mode 100644 index 5e87a07d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fallout-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg deleted file mode 100644 index 2c9c258f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg deleted file mode 100644 index 53b406da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fawlty-towers-1975-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg deleted file mode 100644 index fa3b7604..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg deleted file mode 100644 index 9ad599b3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-firefly-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg deleted file mode 100644 index e5a83462..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg deleted file mode 100644 index bd3ccea5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fleabag-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg deleted file mode 100644 index a42a2c94..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg deleted file mode 100644 index cb94104e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg deleted file mode 100644 index 91ce4909..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg deleted file mode 100644 index 935e4944..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg deleted file mode 100644 index b42ba953..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gilmore-girls-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg deleted file mode 100644 index 1271d577..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg deleted file mode 100644 index 1fca7bf6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ginny-georgia-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg deleted file mode 100644 index b9ef40fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg deleted file mode 100644 index 41c051e0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-gravity-falls-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg deleted file mode 100644 index 38e856b0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg deleted file mode 100644 index 2427108b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-hellcats-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg deleted file mode 100644 index 66bb4d00..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-high-potential-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg deleted file mode 100644 index 9e4171e3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg deleted file mode 100644 index e80c7999..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-change-your-mind-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg deleted file mode 100644 index a605895b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg deleted file mode 100644 index eb5fc487..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg deleted file mode 100644 index c347e016..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg deleted file mode 100644 index f3a21b66..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-interrogation-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg deleted file mode 100644 index e8120387..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg deleted file mode 100644 index c278e37e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-invincible-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg deleted file mode 100644 index cbdc6525..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg deleted file mode 100644 index 652923ee..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg deleted file mode 100644 index 79e7d6bd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg deleted file mode 100644 index 45f5ff22..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-jujutsu-kaisen-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg deleted file mode 100644 index 480b2a8e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg deleted file mode 100644 index 062496fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-kaiju-no-8-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg deleted file mode 100644 index 009fbfc2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg deleted file mode 100644 index 3d348798..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-landman-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg deleted file mode 100644 index c85a210b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg deleted file mode 100644 index a9789909..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-last-samurai-standing-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg deleted file mode 100644 index 1249a8f3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg deleted file mode 100644 index b1229ed8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-lord-of-the-flies-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg deleted file mode 100644 index 3694bfc4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg deleted file mode 100644 index 08449aa9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-death-robots-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg deleted file mode 100644 index 5cea5b52..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg deleted file mode 100644 index 62ccf357..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-love-is-blind-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg deleted file mode 100644 index 9d46de1d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg deleted file mode 100644 index a1985217..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maniac-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg deleted file mode 100644 index 20748a70..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg deleted file mode 100644 index abe1b2e2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg deleted file mode 100644 index bd459fde..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg deleted file mode 100644 index a8797fe5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-minx-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg deleted file mode 100644 index 21674163..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg deleted file mode 100644 index 925d5c49..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-mussolini-son-of-the-century-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg deleted file mode 100644 index f5b2f94c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-my-hero-academia-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg deleted file mode 100644 index d80e17c1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg deleted file mode 100644 index f86cd698..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-neon-genesis-evangelion-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg deleted file mode 100644 index 58805e67..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg deleted file mode 100644 index e7206b88..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-norsemen-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg deleted file mode 100644 index bc22e11a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-offspring-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg deleted file mode 100644 index 3fa4a282..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg deleted file mode 100644 index 286e6260..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-one-piece-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg deleted file mode 100644 index 0b768d54..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg deleted file mode 100644 index a6b147a4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-only-murders-in-the-building-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg deleted file mode 100644 index 23a242da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg deleted file mode 100644 index 43ac4428..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg deleted file mode 100644 index 34b1ed84..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-over-the-garden-wall-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg deleted file mode 100644 index da587ec3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg deleted file mode 100644 index 25ceea88..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-peaky-blinders-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg deleted file mode 100644 index e64c0268..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg deleted file mode 100644 index 1ea4711b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-pluribus-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg deleted file mode 100644 index d68a4060..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg deleted file mode 100644 index 97d481d6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-record-of-ragnarok-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg deleted file mode 100644 index 1c4c0ffc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rick-and-morty-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg deleted file mode 100644 index 2e7399c1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg deleted file mode 100644 index de0b32dd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-ripley-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg deleted file mode 100644 index fe7025e4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg deleted file mode 100644 index 296ac67a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rivals-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg deleted file mode 100644 index f0f7a19e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg deleted file mode 100644 index b698f8a6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-rooster-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg deleted file mode 100644 index 3186e89a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg deleted file mode 100644 index ed9358b3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sakamoto-days-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg deleted file mode 100644 index 445862dc..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg deleted file mode 100644 index cf50fc09..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-say-nothing-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg deleted file mode 100644 index 54d2a13e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg deleted file mode 100644 index 1b6e67f6..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-secret-level-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg deleted file mode 100644 index 8d081a15..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg deleted file mode 100644 index 2fb1c815..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg deleted file mode 100644 index a24c03bd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg deleted file mode 100644 index 5a29d6f9..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-severance-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg deleted file mode 100644 index 57beacc1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-sex-and-the-city-1998-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg deleted file mode 100644 index bcc1c4b7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg deleted file mode 100644 index b493bc7b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-silo-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg deleted file mode 100644 index 4d2c02ad..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg deleted file mode 100644 index 559d6bd1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-six-feet-under-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg deleted file mode 100644 index c7ec326b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg deleted file mode 100644 index 2540dad7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-skymed-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg deleted file mode 100644 index feb777b2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg deleted file mode 100644 index a2e91496..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-so-you-think-you-can-dance-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg deleted file mode 100644 index cd0aec39..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-south-park-1997-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg deleted file mode 100644 index 8764e6eb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg deleted file mode 100644 index fc326930..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-spy-x-family-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg deleted file mode 100644 index 8d41cb22..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg deleted file mode 100644 index 4d254cb0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-100-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg deleted file mode 100644 index 53e1d593..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg deleted file mode 100644 index 72ca19db..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg deleted file mode 100644 index 9beacde2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-afterparty-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg deleted file mode 100644 index d72d7c7e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg deleted file mode 100644 index 7e4e7be2..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-baby-sitters-club-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg deleted file mode 100644 index 9c9cca57..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bear-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg deleted file mode 100644 index e7727a79..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg deleted file mode 100644 index c78c5cf0..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-beast-in-me-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg deleted file mode 100644 index dd84deab..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg deleted file mode 100644 index f663eb8b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-bold-type-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg deleted file mode 100644 index a834d9fb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg deleted file mode 100644 index f67ff16f..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-boys-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg deleted file mode 100644 index 42256fc1..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg deleted file mode 100644 index a4039011..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-capture-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg deleted file mode 100644 index b25ab5f3..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg deleted file mode 100644 index 2eb48b62..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-diplomat-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg deleted file mode 100644 index 3f8bdeec..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-durrells-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg deleted file mode 100644 index 61a310d8..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg deleted file mode 100644 index f0332826..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-final-problem-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg deleted file mode 100644 index a358664c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg deleted file mode 100644 index fd252088..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-four-seasons-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg deleted file mode 100644 index 1de4a3ca..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg deleted file mode 100644 index 7bb38abd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-get-down-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg deleted file mode 100644 index d1758fef..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg deleted file mode 100644 index 034273eb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-hunting-wives-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg deleted file mode 100644 index fa28e38d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg deleted file mode 100644 index 7da462da..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-last-of-us-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg deleted file mode 100644 index ab6086bd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg deleted file mode 100644 index a7086d5e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-legend-of-korra-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg deleted file mode 100644 index 0b88fd4b..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg deleted file mode 100644 index dc387fcd..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg deleted file mode 100644 index c5d17d97..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg deleted file mode 100644 index 71407f36..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg deleted file mode 100644 index 327aa449..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg deleted file mode 100644 index 7ab2dbf5..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-night-manager-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg deleted file mode 100644 index b593444a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg deleted file mode 100644 index ad1ec8ff..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-pitt-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg deleted file mode 100644 index a267b4cb..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg deleted file mode 100644 index 5324dc75..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-tatami-galaxy-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg deleted file mode 100644 index e37f8d5c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg deleted file mode 100644 index 715d0c80..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-walk-in-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg deleted file mode 100644 index 1f747323..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-white-lotus-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg deleted file mode 100644 index acca834e..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg deleted file mode 100644 index f5eb8f59..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-the-witcher-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg deleted file mode 100644 index ad972c17..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg deleted file mode 100644 index 8611eab4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-undercover-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg deleted file mode 100644 index f350a49c..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg deleted file mode 100644 index 547d3b7a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-upload-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg deleted file mode 100644 index 9ae58ec4..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg deleted file mode 100644 index 7d0ba17a..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-vinland-saga-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg deleted file mode 100644 index 03b1c335..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg deleted file mode 100644 index 8c02a0d7..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-widow-s-bay-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg deleted file mode 100644 index 960c2d23..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg deleted file mode 100644 index c7e16a57..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yellowjackets-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg deleted file mode 100644 index ba65ca2d..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-backdrop.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg b/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/wolfskin-design/src/debug/assets/wolfskin-home/assets/tv-yu-yu-hakusho-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json b/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json deleted file mode 100644 index 450f0ece..00000000 --- a/wolfskin-design/src/debug/assets/wolfskin-home/fixture.json +++ /dev/null @@ -1 +0,0 @@ -{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt deleted file mode 100644 index c046c290..00000000 --- a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.content.Context -import org.json.JSONArray -import org.json.JSONObject - -/** The locked fixture is debug-only; release variants cannot construct synthetic household data. */ -class HomeFixture(context: Context) { - private val source = context.assets.open("wolfskin-home/fixture.json").bufferedReader().use { JSONObject(it.readText()) } - private val profiles = source.getJSONArray("profiles").objects() - private val allSeries = profiles.flatMap { it.items("shows") + it.items("anime") } - private val pool = profiles.flatMap { it.items("discover") }.distinctBy { it.identity() } - private val owners = linkedMapOf>().apply { - profiles.forEach { profile -> - listOf("movies", "shows", "anime").flatMap { profile.items(it) }.forEach { item -> - getOrPut(item.identity()) { linkedSetOf() }.add(profile.getString("name")) - } - } - } - - fun snapshot(person: String, state: String = "ready"): HomeSnapshot { - val profile = profiles.first { it.getString("key") == person } - val library = HomeLibrary(profile.items("resume").map(::title), profile.items("nextUp").map(::title), profile.items("recentlyAdded").map(::title), listOf("movies", "shows", "anime").associateWith { key -> profile.items(key).map(::title) }) - val status = when (state) { "unsigned" -> HomeAvailability.SignedOut; "cond-down" -> HomeAvailability.Unavailable; "offline", "cond-cached" -> HomeAvailability.Saved; "loading" -> HomeAvailability.Loading; else -> HomeAvailability.Ready } - val continuation = if (status == HomeAvailability.SignedOut || status == HomeAvailability.Unavailable) listOf(homeDiscoverState(status)) else continuation(profile, state) - var rows = buildHomeRows(library, continuation, state != "jf-down", state == "loading") - rows = rows.map { row -> - if (!row.catalogue) row else { - fun owned(title: HomeTitle): HomeTitle { - val names = owners[title.tmdbId?.let { "${title.type}:$it" } ?: title.id].orEmpty() - val held = title.held || profile.getString("name") in names - val other = if (held) null else names.filter { it != profile.getString("name") }.takeIf { it.isNotEmpty() }?.joinToString(" and ") - return if (held == title.held && other == title.otherOwner) title else title.copy(held = held, otherOwner = other) - } - val cards = row.cards.map(::owned) - row.copy(cards = cards, allCards = if (row.allCards === row.cards) cards else row.allCards.map(::owned)) - } - } - if (state == "missing") { - val missing = profiles.flatMap { person -> listOf("movies", "shows", "anime", "discover", "nextUp", "recentlyAdded").flatMap { key -> person.items(key).filterIndexed { index, _ -> index % 3 == 0 } } }.map { it.getString("id") }.toSet() - rows = rows.map { row -> row.copy(cards = row.cards.map { if (it.id in missing) it.copy(poster = null, backdrop = null) else it }) } - } - return HomeSnapshot(rows, status, state != "jf-down" && state != "offline", if (status == HomeAvailability.Saved) "20:42" else null) - } - - private fun continuation(profile: JSONObject, state: String): List = buildList { - val picks = profile.items("discover") - val movies = picks.filter { it.getString("type") == "movie" } - val shows = picks.filter { it.getString("type") == "tv" } - val played = listOf("movies", "shows", "anime").flatMap { profile.items(it) }.filter { it.optBoolean("played") } - val groups = linkedMapOf>>() - (movies + shows).forEach { candidate -> - played.map { it to genres(it).intersect(genres(candidate)).size }.filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) -> - groups.getOrPut(seed.identity()) { seed to mutableListOf() }.second.add(candidate) - } - } - val seen = mutableSetOf() - if (state == "picks-unavailable" || state == "no-seeds") { - add(HomeRow("picks-na", HomeRowKind.Note, text = if (state == "no-seeds") "Watch something to get picks for ${profile.getString("name")}." else "Picks unavailable right now.", calm = state == "no-seeds")) - } else { - val groupRows = groups.values.take(3) - fun take(items: List, limit: Int): List = items.filter { it.identity() !in seen }.take(limit).filter { seen.add(it.identity()) } - val movieRow = take(movies, minOf(12, kotlin.math.ceil(movies.size.toDouble() / (groupRows.size + 1)).toInt())) - val showRow = take(shows, minOf(12, kotlin.math.ceil(shows.size.toDouble() / (groupRows.size + 1)).toInt())) - val remaining = (movies + shows).count { it.identity() !in seen } - val size = if (groupRows.isEmpty()) 0 else minOf(8, kotlin.math.ceil(remaining.toDouble() / groupRows.size).toInt()) - val watchedRows = groupRows.map { (seed, items) -> Triple(seed, take(items, size), items) }.filter { it.second.size >= 3 } - if (movieRow.isNotEmpty()) add(shelf("pk-m", "Movies for you", movieRow, movies.size, movies)) - if (showRow.isNotEmpty()) add(shelf("pk-s", "Series for you", showRow, shows.size, shows)) - watchedRows.take(2).forEachIndexed { index, (seed, items, all) -> add(shelf("pk-w$index", "Because you watched ${seed.getString("title")}", items, all.size, all)) } - } - val excluded = flatMap { row -> row.cards }.map { it.id }.toSet() - val base = pool.filter { it.getString("id") !in excluded } - listOf("Netflix", "Prime Video").forEachIndexed { index, service -> - val shift = if (base.isEmpty()) 0 else index * 7 % base.size - val rotated = base.drop(shift) + base.take(shift) - add(shelf("new-$index", "New on $service", rotated.take(12).sortedByDescending { it.optInt("year") })) - } - add(shelf("trending", "Trending", base.take(WolfskinTokens.Behaviour.rowLimit.toInt()), base.size, base)) - listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").forEach { genre -> - val titles = base.filter { genre in genres(it) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.getString("type") == "movie") } - if (titles.isNotEmpty()) add(shelf("genre-$genre", genre, titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), titles.size, titles)) - } - add(HomeRow("explore", HomeRowKind.Explore, "Explore more in Discover")) - } - - private fun shelf(id: String, name: String, items: List, total: Int = items.size, allItems: List = items): HomeRow { - val cards = items.map(::title) - return HomeRow(id, HomeRowKind.Shelf, name, cards, catalogue = true, total = total, allCards = if (allItems === items) cards else allItems.map(::title)) - } - - private fun title(item: JSONObject): HomeTitle = HomeTitle( - id = item.getString("id"), title = item.getString("title"), type = item.getString("type"), - year = item.intOrNull("year"), runtimeSeconds = item.intOrNull("runtimeSeconds"), seasons = item.intOrNull("seasonCount"), - overview = item.optString("overview"), poster = item.stringOrNull("poster")?.let { "file:///android_asset/wolfskin-home/$it" }, - backdrop = item.stringOrNull("backdrop")?.let { "file:///android_asset/wolfskin-home/$it" }, - posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", - seriesTitle = item.stringOrNull("seriesTitle"), season = item.intOrNull("seasonNumber"), episode = item.intOrNull("episodeNumber"), - resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, - played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), - held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), - ) - - private fun ratings(item: JSONObject): List { - val episode = item.optString("type") == "episode" - val series = if (episode) allSeries.firstOrNull { it.optString("title") == item.optString("seriesTitle") } else item - val source = series?.optJSONObject("ratings") ?: JSONObject().apply { - put("rtCritics", series?.opt("criticRating")); put("tmdb", series?.opt("communityRating")) - } - val result = mutableListOf() - fun score(key: String, max: Double): Double? = if (source.isNull(key)) null else source.optDouble(key).takeIf { it.isFinite() && it in 0.0..max } - score("rtCritics", 100.0)?.let { result += HomeRating(when (source.optString("rtCriticsLabel")) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; else -> "tomato" }, it, true) } - score("rtAudience", 100.0)?.let { result += HomeRating(if (source.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright", it, true) } - score("imdb", 10.0)?.takeIf { source.isNull("imdbVotes") || source.optInt("imdbVotes") > 0 }?.let { result += HomeRating("imdb", it) } - if (result.isEmpty()) score("tmdb", 10.0)?.takeIf { source.isNull("tmdbVotes") || source.optInt("tmdbVotes") > 0 }?.let { result += HomeRating("tmdb", it) } - if (episode && !item.isNull("communityRating")) { - val score = item.optDouble("communityRating") - if (score.isFinite() && score in 0.0..10.0) { result.removeAll { it.mark == "tmdb" }; result += HomeRating("tmdb", score, episode = true) } - } - return result - } - - private fun genres(item: JSONObject): Set = item.optJSONArray("genres")?.let { array -> (0 until array.length()).flatMap { index -> when (val name = array.getString(index)) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(name) } }.toSet() }.orEmpty() - private fun JSONObject.identity(): String = if (!isNull("tmdbId")) "${optString("type")}:${optInt("tmdbId")}" else getString("id") - private fun JSONObject.items(key: String): List = getJSONArray(key).objects() - private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } - private fun JSONObject.intOrNull(key: String): Int? = if (isNull(key)) null else optInt(key) - private fun JSONObject.stringOrNull(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt deleted file mode 100644 index 5f9a0742..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.TextStyle -import androidx.tv.material3.Text -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.unit.constrainHeight - -/** Captions fade without moving the reserved band under the focused poster. */ -@Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, - titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, - titleOverflow: TextOverflow = TextOverflow.Ellipsis) { - val alpha = remember(title, facts) { Animatable(0f) } - LaunchedEffect(alpha, visible) { - alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) - } - Column(modifier.graphicsLayer { this.alpha = alpha.value }) { - Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) - CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) - } -} - -/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ -@Composable -private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) { - val parts = remember(facts) { facts.split(" · ") } - val muted = WolfskinTokens.Color.muted - val colour = WolfskinTokens.Color.accentText - Layout( - content = { - parts.forEachIndexed { index, part -> - val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) - Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) - if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style)) - } - }, - modifier = Modifier.fillMaxWidth().clipToBounds(), - ) { children, constraints -> - val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() - val basis = IntArray(children.size) - var fixed = gap * (children.size - 1).coerceAtLeast(0) - var flexible = 0 - fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) - children.forEachIndexed { index, child -> - val width = child.maxIntrinsicWidth(constraints.maxHeight) - basis[index] = width - if (shrinks(index)) flexible += width else fixed += width - } - val available = (constraints.maxWidth - fixed).coerceAtLeast(0) - val ratio = if (flexible > available) available.toFloat() / flexible else 1f - val measured = children.mapIndexed { index, child -> - val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] - child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) - } - layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { - var x = 0 - measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt deleted file mode 100644 index 4c7e5f8d..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G - -/** Shelves and continuous grids share the same recoverable tail instead of a page selector. */ -@Composable -fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(WolfskinTokens.Radius.card) - Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) - .focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) - .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), - verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { - Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) - Text(if (retry) "Try again" else "Show more", color = C.fg, style = WolfskinTokens.Type.button, textAlign = TextAlign.Center) - Text(if (retry) "More titles couldn’t load" else "Only titles already shown came back", color = C.muted, - style = G.tailDescription, textAlign = TextAlign.Center) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt deleted file mode 100644 index 50608f0b..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInFixture.kt +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import org.json.JSONObject - -/** Debug assets and native screenshots consume the same projection of the frozen prototype. */ -fun readComingInFixture(json: String, poster: (String) -> Any?): List { - val items = JSONObject(json).getJSONArray("items") - return List(items.length()) { index -> - val item = items.getJSONObject(index) - val scores = item.getJSONArray("ratings") - ComingInRequest( - id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"), - title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"), - actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")), - poster = poster(item.getString("poster")), - ratings = List(scores.length()) { scoreIndex -> - val score = scores.getJSONObject(scoreIndex) - ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage")) - }, - ) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt deleted file mode 100644 index e7ae86a9..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInLineBox.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Dp - -/** A CSS line box has its own height and baseline, independent of native leading rounding. */ -internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints -> - val text = measurable.measure(constraints.copy(minHeight = 0)) - val baselineOffset = baseline.roundToPx() - text[FirstBaseline] - layout(text.width, height.roundToPx()) { - text.placeRelative(0, baselineOffset) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt deleted file mode 100644 index bd990e32..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.foundation.background -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.tv.material3.Text -import java.util.Locale -import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T - -/** Percentages remain whole; ten-point scores use the television's locale. */ -fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String = - if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) - -/** This row always displays the full supplier set rather than the card plate's shortened prefix. */ -@Composable -fun ComingInRatings(ratings: List, color: Color) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) { - ratings.forEach { rating -> - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { - val mark = when (rating.mark) { - "fresh" -> R.drawable.coming_in_rating_fresh - "tomato" -> R.drawable.coming_in_rating_fresh - "popcorn" -> R.drawable.coming_in_rating_upright - "rotten" -> R.drawable.coming_in_rating_rotten - "upright" -> R.drawable.coming_in_rating_upright - "spilled" -> R.drawable.coming_in_rating_spilled - "imdb" -> R.drawable.coming_in_rating_imdb - else -> R.drawable.coming_in_rating_tmdb - } - val wide = rating.mark == "imdb" || rating.mark == "tmdb" - Image(painterResource(mark), contentDescription = rating.mark, - modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) - .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3) - .graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f }, - contentScale = ContentScale.Fit) - Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) - } - } - } -} - -private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt deleted file mode 100644 index 23c5c596..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import coil3.compose.AsyncImagePainter -import androidx.compose.runtime.mutableStateOf -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T - -/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ -@Composable -fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { - val state = shell.requests - val data = state.snapshot - val ratingsReader = LocalScreenRatings.current - LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { - if (ratingsReader == null) state.awaitFocusedRatings() - else if (data.signedIn) state.focused?.let { item -> - ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> - state.updateRatings(item.id, values.map { rating -> - val mapped = rating.homeRating() - ComingInRating(mapped.mark, mapped.score, mapped.percentage) - }) - } - } - } - Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) - Column(modifier.fillMaxHeight().width(L.sheetWidth) - .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) - .drawWithCache { - val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { - color = C.surface.toArgb() - setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb()) - } - onDrawBehind { - drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } - } - }.pointerInput(Unit) { - awaitPointerEventScope { - while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } - } - } - .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) - .testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) { - Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) - when { - data.status == ComingInStatus.Loading && data.items.isEmpty() -> { - LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { - items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } - } - SheetButton("Close") { shell.closeRequests() } - } - data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { - Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY), - verticalArrangement = Arrangement.spacedBy(T.receiptGap)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) { - Glyph("alert", C.warn, Modifier.size(T.receiptGlyph)) - Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn) - } - Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) - } - SheetButton("Try again", primary = true) { shell.retryRequests() } - } - data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) - else -> { - if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) - data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } - if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) - val start = state.windowStart - val visibleRows = remember(data, start, state.windowSize) { - data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) - } - LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { - itemsIndexed(visibleRows, - key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> - ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, - state.ratingsVisible && data.signedIn && item.id == state.focusedId, - onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) - } - } - if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } - } - } - } -} - -@Composable -private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { - Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } -} - -@Composable -private fun ComingInSkeleton() { - Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { - Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb))) - Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) { - Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius))) - Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) - } - } -} - -/** Thumbnail slots do not resize when the text or the readiness block grows. */ -@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) -@Composable -fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, - showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { - val foreground = if (focused) C.onFocus else C.fg - val secondary = if (focused) C.onFocus else C.muted - val context = LocalContext.current - val request = remember(item.poster, context) { - item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } - } - val posterReady = remember(request) { mutableStateOf(false) } - Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) - .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .pointerInput(item.id) { - awaitPointerEventScope { - while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() - } - }.clickable(onClick = onClick) - .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { - AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, - modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) - .background(if (posterReady.value) Color.Transparent else C.surface3), - onState = { posterReady.value = it is AsyncImagePainter.State.Success }, - contentScale = androidx.compose.ui.layout.ContentScale.Crop) - Column(Modifier.weight(1f)) { - Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), - style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), - style = ComingInTypography.caption, color = secondary) - FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { - Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph)) - Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary) - } - if (showRatings) ComingInRatings(item.ratings, foreground) - } - if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), - horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { - item.stage.readiness.forEachIndexed { index, word -> - Row { - val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" } - if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) - Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) - } - } - } - } - if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) - } -} - -private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt deleted file mode 100644 index f8aa27df..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -import kotlinx.coroutines.delay -/** Household acquisition stages, ordered independently from their display wording. */ -enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List) { - Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), - Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), - AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), - Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), - Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), - Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), - Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), -} - -/** Ratings have already passed the supplier's observed-score and vote checks. */ -data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false) - -/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */ -data class ComingInRequest( - val id: String, - val mediaType: String, - val tmdbId: Long, - val title: String, - val requesterKey: String, - val requesterName: String, - val actorKey: String?, - val actorName: String?, - val stage: ComingInStage, - val poster: Any? = null, - val ratings: List = emptyList(), -) { - val ratingKey = "$mediaType:$tmdbId" - fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName - fun actor(viewer: ShellPerson): String = when { - actorKey == "shared" -> "Shared" - actorKey != null && actorKey == viewer.key -> "you" - else -> actorName ?: "Unknown" - } -} - -enum class ComingInStatus { Ready, Loading, Unavailable } - -/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */ -data class ComingInSnapshot( - val items: List = emptyList(), - val status: ComingInStatus = ComingInStatus.Unavailable, - val partial: Boolean = false, - val signedIn: Boolean = false, - val savedAt: String? = null, -) { - val ordered = items.sortedBy { it.stage.rank } - val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } -} - -/** A bounded window and stable request id preserve focus across refreshes and title round trips. */ -class ComingInState { - var snapshot by mutableStateOf(ComingInSnapshot()) - private set - var focusedId by mutableStateOf(null) - private set - var expanded by mutableStateOf(false) - private set - var ratingsVisible by mutableStateOf(false) - private set - var recoveryFocused by mutableStateOf(false) - private set - private val cachedRatings = mutableSetOf() - val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0) - val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index) - val windowSize: Int get() = if (expanded) 4 else 5 - val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0)) - - fun update(value: ComingInSnapshot) { - snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) { - snapshot.copy(status = value.status) - } else value - if (!snapshot.signedIn) cachedRatings.clear() - if (snapshot.ordered.none { it.id == focusedId }) focus(snapshot.ordered.firstOrNull()?.id) - ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings - if (snapshot.status == ComingInStatus.Ready) recoveryFocused = false - } - - fun focus(id: String?) { - focusedId = id - recoveryFocused = false - expanded = false - ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings - } - - fun updateRatings(id: String, values: List) { - snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it }) - snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) } - ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings - } - - /** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */ - suspend fun awaitFocusedRatings() { - val item = focused ?: return - if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return - delay(WolfskinTokens.Motion.ratingDwell.toLong()) - if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return - cachedRatings.add(item.ratingKey) - ratingsVisible = true - } - - /** Return true only when a row requests a title; every direction remains trapped in the sheet. */ - fun key(key: ShellKey): Boolean { - if (recoveryFocused) { - if (key == ShellKey.Up) recoveryFocused = false - return false - } - when (key) { - ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id) - ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true - else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id) - ShellKey.Right -> if (focused != null) expanded = true - ShellKey.Left -> expanded = false - ShellKey.Ok -> return focused != null - else -> Unit - } - return false - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt deleted file mode 100644 index a8f3b3e0..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInTypography.kt +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.text.font.FontWeight - -/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */ -internal object ComingInTypography { - private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) - val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox) - val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox) - val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox) - val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox) - val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox) - val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold) - val readiness = caption.copy(fontWeight = FontWeight.Bold) - val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum") -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt deleted file mode 100644 index 4f1d281a..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.layout.LastBaseline -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.LayoutModifier -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import kotlin.math.roundToInt - -internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox - fun Density.exactHeight(height: Int): Float { - val margins = top.toPx() + bottom.toPx() - return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins - } - override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) { - val line = lineHeight.toPx() - val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1 - val textHeight = lines * line - val offset = top.toPx() + (textHeight - height) / 2f - layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) { - placeRelative(0, offset.roundToInt()) - } - } -} - -private class CssColumnHeight : ParentDataModifier { - var pixels = 0f - override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight -} - -/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ -@Composable -internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { - val exact = remember { CssColumnHeight() } - Layout(content, modifier.then(exact)) { children, constraints -> - val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } - fun height(index: Int): Float = when (val data = children[index].parentData) { - is CssLineBox -> with(data) { exactHeight(measured[index].height) } - is CssColumnHeight -> data.pixels - else -> measured[index].height.toFloat() - } - val space = gap.toPx() - var total = 0f - var width = constraints.minWidth - for (index in measured.indices) { - total += height(index) + if (index == measured.lastIndex) 0f else space - width = maxOf(width, measured[index].width) - } - exact.pixels = total - layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) { - var y = 0f - for (index in measured.indices) { - measured[index].placeRelative(0, y.roundToInt()) - y += height(index) + space - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt deleted file mode 100644 index 92e63ad9..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import coil3.compose.AsyncImage -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Native captures provide decoded local art; neither posters nor heroes perform image HTTP reads. */ -val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null } - -/** Decode requests use the actual art slot, with RGB565 only for upright posters. */ -@Composable -fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false, onError: () -> Unit = {}) { - if (path == null) return - val bitmap = LocalDiscoverArt.current?.invoke(path) - val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) } - val filter = remember(dim) { if (dim) ColorFilter.colorMatrix(ColorMatrix().apply { - setToSaturation(D.ambientSaturation) - this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } - }) else null } - val context = LocalContext.current - val request = remember(context, path, poster) { - WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) - } - if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) - else AsyncImage( - model = request, - imageLoader = WolfskinImages.loader(context), - contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter, - onError = { onError() }, - ) -} - -/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */ -@Composable -fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) { - val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) } - Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) { - if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art")) - else if (title.posterOnly) { - DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset) - .wrapContentSize(Alignment.TopStart, unbounded = true) - .size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2) - .graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true, onError = { failed.value = true }) - DiscoverArtwork(title.poster, Modifier.offset( - WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth, - if (detail) L.navBand else D.heroSheetTop, - ).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight) - .dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow( - radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity), - offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY))) - .clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true }) - } else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true }) - Box(Modifier.fillMaxSize().drawWithCache { - val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width) - onDrawBehind { drawRect(scrim) } - }) - Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) - } -} - -/** Missing art is deterministic and quiet; it does not imply a provider read succeeded. */ -fun discoverFallback(title: String): Brush { - val hue = title.fold(0) { hash, char -> (hash * 31 + char.code) % 360 }.toFloat() - return Brush.linearGradient(listOf(Color.hsl(hue, .22f, .22f), Color.hsl(hue, .26f, .10f))) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt deleted file mode 100644 index 488d90d3..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverCardProjection.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -/** Discover metadata projects into the same poster and grid contract as Libraries. */ -internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle { - val receipts = state.requests[key].orEmpty() - val held = state.shell.person.key in heldBy - val availability = when { - held -> "In your library" - receipts.isNotEmpty() -> { - val recipients = receipts.joinToString(", ") { request -> - if (request.requester == state.shell.person.key) "you" - else state.shell.people.firstOrNull { it.key == request.requester }?.name ?: request.requester.orEmpty() - } - "Requested for $recipients · ${receipts.first().stage}" - } - heldBy.isNotEmpty() -> "In ${heldBy.joinToString(" and ") { key -> state.shell.people.firstOrNull { it.key == key }?.name ?: key }}’s library" - else -> "" - } - return LibraryTitle(id = key, title = title, type = type, year = year, - runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster, - ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability, - badge = when { - held -> "held" - receipts.isEmpty() -> null - receipts.all { it.stage == "Failed" } -> "failed" - else -> "requested" - }) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt deleted file mode 100644 index 396a5862..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverFixture.kt +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import org.json.JSONArray -import org.json.JSONObject - -/** Projects supplied prototype data; this class never loads a bundled fixture or contacts a server. */ -class DiscoverFixture(json: String, private val artPath: (String) -> String = { it }) { - private val profiles = JSONObject(json).getJSONArray("profiles").objects() - private val holdings = profiles.associate { profile -> - profile.getString("key") to listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } - .map { identity(it) }.toSet() - } - private val pool = profiles.flatMap { it.getJSONArray("discover").objects() }.distinctBy(::identity).map(::title) - val services = listOf("Netflix", "Prime Video", "Disney+", "HBO Max", "Apple TV+", "Videoland", "Hulu", "NPO Start", "Crunchyroll") - .mapIndexed { index, name -> DiscoverService(index.toLong(), name, discoverServiceHue(name)) } - - fun snapshot(person: String): DiscoverSnapshot { - val profile = profiles.first { it.getString("key") == person } - val picks = profile.getJSONArray("discover").objects().map(::title) - val played = listOf("movies", "shows", "anime").flatMap { profile.getJSONArray(it).objects() } - .filter { it.optBoolean("played") } - val groups = linkedMapOf>>() - picks.sortedBy { if (it.type == "movie") 0 else 1 }.forEach { pick -> - played.map { it to normalizedGenres(it.getJSONArray("genres").strings()).intersect(normalizedGenres(pick.genres)).size } - .filter { it.second > 0 }.sortedByDescending { it.second }.take(2).forEach { (seed, _) -> - groups.getOrPut(identity(seed)) { seed.getString("title") to mutableListOf() }.second.add(pick) - } - } - val lanes = services.mapIndexed { index, service -> - val source = rotate(pool, index * 7).sortedByDescending { it.year } - DiscoverLane("new-$index", "New on ${service.name}", source.take(12), fullTitles = source) - } + listOf(DiscoverLane("trending", "Trending", pool.take(20), pool.size, fullTitles = pool)) + - listOf("Comedy", "Animation", "Science Fiction", "Horror", "Romance").map { genre -> - val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } - DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) - } - return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) - } - - fun service(service: DiscoverService, type: String): List { - val base = rotate(pool.filter { it.type == type }, service.id.toInt() * 5) - val noun = if (type == "movie") "movies" else "series" - val genres = base.flatMap { it.genres }.groupingBy { it }.eachCount().entries.sortedByDescending { it.value }.take(3) - return listOf( - DiscoverLane("sv-pop", "Popular $noun", base.drop(1).take(20)), - DiscoverLane("sv-new", "New $noun", base.sortedByDescending { it.year }.take(20)), - DiscoverLane("sv-top", "Top rated $noun", rotate(base, 11).take(20)), - ) + genres.map { genre -> DiscoverLane("sv-g-${genre.key}", "${genre.key} $noun", base.filter { genre.key in it.genres }.take(20)) } - } - - fun find(name: String): DiscoverTitle = (pool + profiles.flatMap { p -> listOf("movies", "shows", "anime").flatMap { p.getJSONArray(it).objects() }.map(::title) }).first { it.title == name } - - private fun title(item: JSONObject): DiscoverTitle = DiscoverTitle( - tmdb = item.optLong("tmdbId", item.getString("id").hashCode().toLong().let { if (it < 0) -it else it }), - type = item.getString("type"), title = item.getString("title"), year = item.nullableInt("year"), - genres = item.getJSONArray("genres").strings(), overview = item.optString("overview"), - poster = item.nullableString("poster")?.let(artPath), backdrop = item.nullableString("backdrop")?.let(artPath), - posterOnly = item.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", - runtimeSeconds = item.nullableInt("runtimeSeconds"), seasonCount = item.nullableInt("seasonCount"), - ratings = ratings(item.optJSONObject("ratings")), heldBy = holdings.filterValues { identity(item) in it }.keys, - ) - - private fun ratings(json: JSONObject?): List = buildList { - if (json == null) return@buildList - for ((field, source, label) in listOf(Triple("rtCritics", "rtCritics", "rtCriticsLabel"), Triple("rtAudience", "rtAudience", "rtAudienceLabel"), Triple("imdb", "imdb", ""))) { - if (!json.isNull(field)) add(DiscoverRating(source, json.getDouble(field), json.nullableString(label))) - } - if (isEmpty() && !json.isNull("tmdb") && json.optInt("tmdbVotes") > 0) add(DiscoverRating("tmdb", json.getDouble("tmdb"))) - } - - private fun identity(json: JSONObject): String = "${json.getString("type")}:${json.optLong("tmdbId", json.getString("id").hashCode().toLong().let { if (it < 0) -it else it })}" - private fun rotate(items: List, offset: Int): List = if (items.isEmpty()) items else items.drop(offset % items.size) + items.take(offset % items.size) - private fun normalizedGenres(genres: List): Set = genres.flatMap { - when (it) { "Sci-Fi & Fantasy" -> listOf("Science Fiction", "Fantasy"); "Action & Adventure" -> listOf("Action", "Adventure"); "Sci-Fi" -> listOf("Science Fiction"); else -> listOf(it) } - }.toSet() -} - -private fun JSONArray.objects(): List = List(length()) { getJSONObject(it) } -private fun JSONArray.strings(): List = List(length()) { getString(it) } -private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } -private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else getInt(key) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt deleted file mode 100644 index cd66673f..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import kotlin.math.ceil - -/** Dropped seed groups must not suppress titles from the later catalogue lanes. */ -internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List, filter: String, hidden: Map): List { - fun matches(item: DiscoverTitle) = filter == "all" || item.type == filter - val seen = heroes.mapTo(mutableSetOf()) { it.key } - val result = mutableListOf() - fun take(items: List, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) } - if (snapshot.status == DiscoverStatus.Ready) { - val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) - for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) { - val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } - val count = all.count { it.key !in seen } - val items = take(all, minOf(12, ceil(count.toDouble() / (groups.size + 1)).toInt())) - if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all) - } - val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt()) - groups.forEachIndexed { index, seed -> - val items = take(seed.items, size) - if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) - } - } - val displayed = heroes.mapTo(mutableSetOf()) { it.key } - result.forEach { lane -> lane.titles.forEach { displayed.add(it.key) } } - if (snapshot.services.isNotEmpty()) result += DiscoverLane("svc", "Services", emptyList()) - snapshot.lanes.forEach { lane -> - val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden } - val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) - val paged = lane.hasMore - val tail = when { - lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore -> lane.tail - paged && items.isEmpty() -> DiscoverTail.ShowMore - paged || (!lane.id.startsWith("new-") && source.size > items.size) -> DiscoverTail.SeeAll - else -> DiscoverTail.None - } - if (items.isNotEmpty() || lane.pending || tail == DiscoverTail.Retry || tail == DiscoverTail.ShowMore) { - result += lane.copy(titles = items, fullTitles = source, total = if (paged) lane.total else if (lane.id.startsWith("new-")) items.size else source.size, tail = tail) - if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } - } - } - val hiddenItems = (snapshot.hidden + snapshot.picks).distinctBy { it.key }.filter { it.key in hidden && matches(it) } - if (hiddenItems.isNotEmpty()) result += DiscoverLane("hidden", "Hidden picks", hiddenItems, hidden = true) - return result -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt deleted file mode 100644 index 2fe37462..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -/** Catalogue identity is stable across lanes, filters, requests and dismissals. */ -data class DiscoverTitle( - val tmdb: Long, - val type: String, - val title: String, - val year: Int? = null, - val genres: List = emptyList(), - val overview: String = "", - val poster: String? = null, - val backdrop: String? = null, - val posterOnly: Boolean = false, - val runtimeSeconds: Int? = null, - val seasonCount: Int? = null, - val seasons: List? = null, - val ratings: List = emptyList(), - val heldBy: Set = emptySet(), - val score: Double = 0.0, -) { - val key: String get() = "$type:$tmdb" - val typeWord: String get() = if (type == "movie") "Movie" else "Series" -} - -/** Observed source marks keep labels separate from score values. */ -data class DiscoverRating(val source: String, val score: Double, val label: String? = null) - -/** A contribution group preserves feed order rather than assigning a visible rank. */ -data class DiscoverSeed(val title: String, val items: List) - -/** Services carry their catalogue id and a text wordmark, never third-party logos. */ -data class DiscoverService(val id: Long, val name: String, val hue: Float) - -/** Locked service accents are shared by fixture and live catalogue providers. */ -fun discoverServiceHue(name: String): Float = when (name) { - "Netflix" -> 355f - "Prime Video", "Amazon Prime Video" -> 200f - "Disney+", "Disney Plus" -> 230f - "HBO Max", "Max" -> 265f - "Videoland" -> 20f - "Hulu" -> 140f - "NPO Start" -> 25f - "Crunchyroll" -> 30f - else -> 0f -} - -enum class DiscoverStatus { Ready, NeedsSignIn, Unavailable, Loading, NoSeeds, PicksUnavailable } -enum class DiscoverTail { None, SeeAll, Retry, ShowMore } - -/** Provider data is immutable; screen focus never mutates source ordering. */ -data class DiscoverSnapshot( - val picks: List = emptyList(), - val seeds: List = emptyList(), - val services: List = emptyList(), - val lanes: List = emptyList(), - val hidden: List = emptyList(), - val status: DiscoverStatus = DiscoverStatus.NeedsSignIn, - val savedAt: String? = null, - val reusesFixtureLanes: Boolean = false, - val reachable: Boolean = true, - val requestsAllowed: Boolean = true, -) - -/** Lane identity survives lazy recycling and provider refreshes. */ -data class DiscoverLane( - val id: String, - val title: String, - val titles: List, - val total: Int = titles.size, - val tail: DiscoverTail = if (total > titles.size) DiscoverTail.SeeAll else DiscoverTail.None, - val pick: Boolean = false, - val hidden: Boolean = false, - val fullTitles: List = titles, - val pending: Boolean = false, - val hasMore: Boolean = false, -) - -/** Caller identity is deliberately absent: the authenticated client supplies the actor. */ -data class DiscoverRequest(val title: DiscoverTitle, val requester: String, val profile: String, val fromRecommendation: Boolean = true) - -/** Request outcomes distinguish uncertainty from an acknowledged receipt. */ -data class DiscoverRequestResult( - val phase: RequestPhase, - val actor: String? = null, - val stage: String = "Waiting", - val requester: String? = null, -) - -enum class RequestPhase { Choose, LoadingRecipients, RecipientsUnavailable, Sending, Receipt, NotConfirmed, Pending, Refused } - -/** Production adapters use the generated Conductarr client; fixture adapters cannot send HTTP. */ -interface DiscoverOperations { - suspend fun request(intent: DiscoverRequest): DiscoverRequestResult - suspend fun check(intent: DiscoverRequest): DiscoverRequestResult - suspend fun hide(profile: String, title: DiscoverTitle): Boolean - suspend fun restore(profile: String, title: DiscoverTitle): Boolean - suspend fun refresh(): DiscoverSnapshot -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt deleted file mode 100644 index df050bc3..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -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.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.layout.Layout -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ -@Composable -fun DiscoverOverlay(state: DiscoverState) { - val overlay = state.overlay - if (overlay != null && !state.shell.pickerVisible) { - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { - Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() - .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, - color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), - offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) - .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { - when (overlay) { - is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) - is DiscoverOverlayState.Hidden -> { - ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } - DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } - } - } - is DiscoverOverlayState.Restore -> { - Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) - Text("Hidden from ${state.shell.person.name}’s picks.", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), - color = C.muted, style = DiscoverType.body) - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } - DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } - } - } - } - } - } - } - state.toast?.let { RecoveryToast(it) } -} - -@Composable -private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { - Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) - Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", - Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) - if (state.isSignedIn) SheetRatings(sheet.title.ratings) - when (sheet.phase) { - RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) - RequestPhase.LoadingRecipients -> { - Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) - repeat(3) { - Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding), - horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape)) - Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius))) - } - } - Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton("Close", true, primary = false) { state.overlay = null } - } - } - else -> RequestOutcome(state, sheet) - } -} - -/** The inline ratings baseline includes the first mark; the inherited text strut keeps its descent. */ -@Composable -private fun SheetRatings(ratings: List) { - if (ratings.isEmpty()) return - Layout(content = { - DiscoverRatings(ratings) - Text(" ", color = C.muted, style = DiscoverType.body) - }) { children, constraints -> - val row = children[0].measure(constraints.copy(minHeight = 0)) - val strut = children[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) - val mark = DiscoverType.ratings.fontSize.toPx() - val baseline = (row.height + mark) / 2f - val descent = maxOf(row.height - baseline, (strut.height - strut[FirstBaseline]).toFloat()) - val height = maxOf(baseline, strut[FirstBaseline].toFloat()) + descent - val top = D.sheetRatingTop.roundToPx() - val bottom = D.sheetRatingBottom.roundToPx() - layout(row.width, kotlin.math.round(height).toInt() + top + bottom) { - row.placeRelative(0, top) - } - } -} - -@Composable -private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { - Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) - sheet.people.forEachIndexed { index, person -> - val focused = sheet.focus == index - val disabled = person.key in sheet.title.heldBy - Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) - .focusScale(focused, WolfskinTokens.Focus.scaleRow) - .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } - .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { - Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) - Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) - if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason) - else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape) - .border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) { - if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape)) - } - } - } - if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) - else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) - Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } - } -} - -@Composable -private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { - val phase = sheet.phase - val name = sheet.selectedPerson.name - val actor = sheet.result?.actor - val text = when (phase) { - RequestPhase.Receipt -> "for ${if (sheet.selectedPerson.key == state.shell.person.key) "you" else name}" + actor?.let { " · by $it" }.orEmpty() - RequestPhase.NotConfirmed -> "The request service didn’t answer. Nothing is assumed filed." - RequestPhase.Pending -> "Seerr hasn’t confirmed it yet. Checking again looks first, so nothing is filed twice." - RequestPhase.Refused -> "$name can’t receive requests from this TV." - else -> "Conductarr didn’t say who this TV may request for. Nothing was sent." - } - val heading = when (phase) { - RequestPhase.Receipt -> if (sheet.held) "Adding to $name’s library" else "Requested" - RequestPhase.NotConfirmed -> "Not confirmed" - RequestPhase.Pending -> "Request unresolved" - RequestPhase.Refused -> "Refused" - else -> "Can’t read who can receive requests" - } - ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn, - if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null) - if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { - DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } - DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } - } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } - } -} - -@Composable -private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui.graphics.Color = C.fg, stage: String? = null) { - Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) { - Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon)) - Text(title, color = color, style = DiscoverType.stateTitle) - } - Text(text, color = C.muted, style = DiscoverType.body) - if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) { - val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted } - Glyph("clock", stageColor, Modifier.size(L.buttonIcon)) - Text(stage, color = stageColor, style = DiscoverType.label) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt deleted file mode 100644 index 62a55b57..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverRatings.kt +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -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.Alignment -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.tv.material3.Text -import java.util.Locale -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C - -/** Localized score formatting never infers a tomato label from its percentage. */ -fun formatDiscoverRating(rating: DiscoverRating, locale: Locale): String = - if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score) - -/** Heroes, title destinations and sheets show every observed score without a dwell or truncation. */ -@Composable -fun DiscoverRatings(ratings: List, modifier: Modifier = Modifier) { - if (ratings.isEmpty()) return - val locale = LocalConfiguration.current.locales[0] - val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } } - val style = DiscoverType.ratings - val markSize = with(LocalDensity.current) { style.fontSize.toDp() } - val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } - Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) { - ratings.forEachIndexed { index, rating -> - val resource = when (rating.source) { - "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh - "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright - "imdb" -> R.drawable.ic_rating_imdb - else -> R.drawable.ic_rating_tmdb - } - val muted = rating.source == "rtCritics" && rating.label != "Rotten" && rating.label != "Fresh" && rating.label != "Certified Fresh" - val wordmark = rating.source == "imdb" || rating.source == "tmdb" - Row(horizontalArrangement = Arrangement.spacedBy(markSize * .2857143f), verticalAlignment = Alignment.CenterVertically) { - Image(painterResource(resource), null, Modifier.height(markSize).width(markSize * if (wordmark) 1.8f else 1f), - alpha = if (muted) .55f else 1f, colorFilter = if (muted) desaturated else null) - Text(labels[index], color = C.fg, style = style, maxLines = 1) - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt deleted file mode 100644 index bd6c40ef..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ /dev/null @@ -1,206 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Discover uses the shell's real scroller, Back ladder and per-person focus memory. */ -@Composable -fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { - val shell = state.shell - val focus = remember(state) { object : ShellContentFocus by state {} } - DisposableEffect(focus) { - shell.contentFocus = focus - onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } - } - LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } - LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } - val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) - val ratingsReader = LocalScreenRatings.current - LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt, state.hero?.key, state.title?.key, state.isSignedIn) { - state.plateKey = null - coroutineScope { - if (state.isSignedIn && ratingsReader != null) launch { - val prominent = state.title ?: state.hero - if (prominent != null) ratingsReader.read(prominent.tmdb, prominent.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(prominent, it) } - } - if (state.title == null && state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings || ratingsReader != null)) launch { - if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) - if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) } - state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key - } - } - } - if (state.title != null) { DiscoverTitleDestination(state, modifier); return } - state.collection?.let { - LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false) - return - } - if (state.snapshot.status == DiscoverStatus.Loading) { - val loading = remember(state) { LibraryGridState("Discover", emptyList(), catalogue = true).apply { status = LibraryStatus.Loading } } - LibraryScreen(shell, loading, modifier, offlineArt = LocalDiscoverArt.current, registerFocus = false) - return - } - val density = LocalDensity.current - val list = shell.view.listState - val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) - val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) { - buildList { - add(if (state.hasHero) L.row_phero else L.row_lhead) - add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) - if (!state.hasCatalogue) add(L.row_state) else { - if (note) add(L.row_note) - state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } - } - } - } - LaunchedEffect(state.focusedRow, shell.navFocused, heights) { - if (!shell.navFocused) { - val row = state.focusedRow - val physical = row + if (row >= state.firstShelf && note) 1 else 0 - var top = WolfskinTokens.Shell.zero - for (index in 0 until physical.coerceAtMost(heights.size)) top += heights[index] - val fits = top < D.anchorFitTop && top + heights[physical.coerceAtMost(heights.lastIndex)] <= WolfskinTokens.Shell.stageHeight - D.anchorBottomClearance - val target = if (row <= 1 || fits) 0f else with(density) { (top - L.pinnedLine).toPx() } - var current = WolfskinTokens.Shell.zero - for (index in 0 until list.firstVisibleItemIndex.coerceAtMost(heights.size)) current += heights[index] - animateFrameScroll(list, target - with(density) { current.toPx() } - list.firstVisibleItemScrollOffset) - } - } - Box(modifier.fillMaxSize().testTag("discover-screen")) { - if (state.hasHero && state.service == null && list.firstVisibleItemIndex == 0) state.hero?.let { DiscoverBackdrop(it, modifier = Modifier.graphicsLayer { translationY = -list.firstVisibleItemScrollOffset.toFloat() }) } - LazyColumn(state = list, contentPadding = PaddingValues(bottom = (WolfskinTokens.Shell.stageHeight - L.pinnedLine - heights.last()).coerceAtLeast(D.frameBottomMin)), modifier = Modifier.fillMaxSize()) { - item(key = "discover-header", contentType = if (state.hasHero && state.service == null) "hero" else "header") { - if (state.hasHero && state.service == null) DiscoverHero(state) else LibraryHeader(state.shell.person, state.service?.name ?: "Discover", if (state.service != null) "General interest" else "") - } - item(key = "discover-chips", contentType = "chips") { - if (state.snapshot.status !in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable, DiscoverStatus.Loading)) DiscoverChips(state) - } - if (state.snapshot.status in setOf(DiscoverStatus.NeedsSignIn, DiscoverStatus.Unavailable)) item(key = "discover-state", contentType = "state") { DiscoverStatePanel(state) } - else { - if (state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) item(key = "discover-note", contentType = "note") { - Box(Modifier.height(L.row_note).fillMaxWidth()) { - Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) { - val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn - Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon)) - Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = DiscoverType.body) - } - } - } - itemsIndexed(state.lanes, key = { _, lane -> lane.id }, contentType = { _, _ -> "shelf" }) { index, lane -> DiscoverShelf(state, lane, index + state.firstShelf) } - } - } - } -} - -@Composable -private fun DiscoverHero(state: DiscoverState) { - val title = state.hero ?: return - Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) { - Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon)) - Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label) - } - Spacer(Modifier.height(D.heroKickerGap)) - Text(title.title, Modifier.width(L.heroCopyWidth).testTag("discover-hero-title-box").discoverLineBox(DiscoverType.hero), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.height(D.heroTitleGap)) - Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) { - Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body) - if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body) - if (state.isSignedIn) DiscoverRatings(title.ratings) - if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body) - } - Spacer(Modifier.height(D.heroMetaGap)) - Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.height(D.heroOverviewGap)) - Row(verticalAlignment = Alignment.CenterVertically) { - DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) } - Row(Modifier.padding(start = D.dotMargin), horizontalArrangement = Arrangement.spacedBy(D.dotGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("left", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) - state.heroes.forEachIndexed { index, _ -> Box(Modifier.size(if (index == state.slide) D.dotActive else D.dotSize, D.dotSize).background(if (index == state.slide) C.fg else C.fg.copy(alpha = .3f), CircleShape)) } - Glyph("right", C.fg.copy(alpha = if (state.contentFocused && state.focusedRow == 0) 1f else 0f), Modifier.size(D.dotChevron)) - } - } - } -} - -@Composable -private fun DiscoverChips(state: DiscoverState) { - Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { - (if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) -> - val focused = state.contentFocused && state.focusedRow == 1 && state.column == index - Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) - .border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) - .clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label) - } - } - } -} - -@Composable -private fun DiscoverStatePanel(state: DiscoverState) { - val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn - Box(Modifier.fillMaxWidth().height(L.row_state)) { - Column(Modifier.padding(start = L.safeSide, top = D.stateTop).wrapContentHeight(Alignment.Top, unbounded = true).width(L.stateWidth) - .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) { - Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon)) - Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle) - } - Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = DiscoverType.body) - DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() } - } - } -} - -/** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ -@Composable -fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { - Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) - .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) - .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt deleted file mode 100644 index 92e8317e..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.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.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.zIndex -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Every shelf is lazy and keyed; the focused caption is outside the scaled art slot. */ -@Composable -fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { - val services = lane.id == "svc" - val height = if (services) K.entryH else K.posterH - val cards = rememberLazyListState() - val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 - val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) - val active = state.contentFocused && state.focusedRow == row - val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt() - val first = state.shell.view.cards["first:${lane.id}"] ?: 0 - var initialized by remember { mutableStateOf(false) } - LaunchedEffect(column, count) { - var target = first - if (column > target + slots - 2) target = column - (slots - 2) - if (column < target + 1) target = (column - 1).coerceAtLeast(0) - target = target.coerceIn(0, (count - slots).coerceAtLeast(0)) - state.shell.view.cards["first:${lane.id}"] = target - val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset - if (initialized && offset != null) { - if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) - } else cards.scrollToItem(target) - initialized = true - } - Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { - Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1) - LazyRow(state = cards, userScrollEnabled = false, - contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), - horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { - if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> - Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) - .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) - .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) - .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) - .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { - Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg) - } - } else if (lane.pending) { - items(slots, key = { "pending:$it" }) { - Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card))) - } - } else { - itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> - DiscoverPoster(state, item, active && index == column, Modifier.clickable { - state.focus(row, index) - if (lane.hidden) state.overlay = DiscoverOverlayState.Restore(item) else state.openTitle(item) - }) - } - if (lane.tail != DiscoverTail.None) item(key = "tail:${lane.id}", contentType = if (lane.tail == DiscoverTail.SeeAll) "seeAll" else "tail") { - DiscoverTailCard(lane, active && column == lane.titles.size) { state.focus(row, lane.titles.size); state.key(ShellKey.Ok) } - } - } - } - if (!services && active && !lane.pending) { - val item = lane.titles.getOrNull(column) - Caption( - item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", - if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", - true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), - accentFirstFact = lane.hidden, - accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), - ) - } - } -} - -/** Only the focused poster reads the ratings plate; cached observations paint immediately. */ -@Composable -fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) { - val receipts = state.requests[title.key] - val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) } - LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true, - modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current) -} - -@Composable -internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () -> Unit) { - if (lane.tail == DiscoverTail.SeeAll) LibrarySeeAllCard(lane.total, focused, onClick) - else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) -} - -internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { - val card = title.libraryCard(state) - return (if (hidden) "Hidden from your picks · " else "") + card.facts(true) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt deleted file mode 100644 index f0e2dabb..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ /dev/null @@ -1,421 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** One person owns a Discover view; the shell retains its root list and card memory. */ -class DiscoverState( - val shell: ShellState, - initial: DiscoverSnapshot, - private val operations: DiscoverOperations, - private val scope: CoroutineScope, - private val onPush: (String) -> Unit = {}, - private val onPop: () -> Unit = {}, - private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, - requesters: List = shell.people, - private val onMore: (suspend (String) -> List?)? = null, - private val nowMillis: () -> Long = System::currentTimeMillis, -) : ShellContentFocus { - var snapshot by mutableStateOf(initial) - private set - private var requesters = requesters - - fun updateRequesters(people: List) { - if (requesters == people) return - requesters = people - requestSheets.values.forEach { it.updatePeople(people) } - (overlay as? DiscoverOverlayState.Request)?.state?.let { current -> - if (requestSheets.values.none { it === current }) current.updatePeople(people) - } - } - private var savedTime = initial.savedAt ?: formatSavedTime() - private var serviceFilter by mutableStateOf("movie") - var filter: String - get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0] - set(value) { - if (service != null) serviceFilter = value - else shell.rememberedView("discover").cards["filter"] = DISCOVER_FILTERS.indexOf(value) - } - var slide: Int - get() = (shell.rememberedView("discover").cards["slide"] ?: 0).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) - set(value) { shell.rememberedView("discover").cards["slide"] = value } - var title by mutableStateOf(null) - var service by mutableStateOf(null) - var collection by mutableStateOf(null) - var titleButton by mutableStateOf(0) - private var activeOverlay by mutableStateOf(null) - var overlay: DiscoverOverlayState? - get() = activeOverlay - set(value) { activeOverlay = value; shell.contentOverlayVisible = value != null } - var toast by mutableStateOf(null) - val observedRatings = mutableSetOf() - var plateKey by mutableStateOf(null) - val requests = mutableStateMapOf>() - private val requestSheets = mutableMapOf, RequestSheetState>() - private val hidden = mutableStateMapOf().apply { initial.hidden.forEach { put(it.key, it) } } - private val ratingValues = mutableStateMapOf>() - private lateinit var collectionCards: LinkedHashMap - private var revision by mutableStateOf(0) - private var cachedRevision = -1 - private var cachedFilter = "" - private var cachedService: DiscoverService? = null - private var cachedLanes = emptyList() - private var cachedHeroesRevision = -1 - private var cachedHeroesFilter = "" - private var cachedHeroes = emptyList() - private val dismissalMutex = Mutex() - private val dismissalVersions = mutableMapOf() - private val pendingDismissals = mutableMapOf() - private var rootFilter = "all" - - fun updateSnapshot(value: DiscoverSnapshot) { - snapshot = value - if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() - hidden.clear() - value.hidden.forEach { hidden[it.key] = it } - pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item } - revision++ - } - - fun invalidateLanes() { revision++ } - fun updateLanes(value: List) { snapshot = snapshot.copy(lanes = value); revision++ } - - private suspend fun loadLane(id: String) { - if (id == "offline-recovery") { reload(); return } - val loaded = onMore?.invoke(id) - if (loaded != null) updateLanes(loaded) else reload() - } - - fun updateTitle(value: DiscoverTitle) { - if (title?.key == value.key) title = value - } - - fun ratingsFor(item: DiscoverTitle): List = ratingValues[item.key] ?: item.ratings - - fun updateRatings(item: DiscoverTitle, values: List) { - val ratings = values.map { DiscoverRating(it.source, it.score, it.label) } - ratingValues[item.key] = ratings - fun replace(source: List) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it } - snapshot = snapshot.copy(picks = replace(snapshot.picks), - lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) }, - seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) }) - title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) } - revision++ - } - - override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading - val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn - val hasCatalogue: Boolean get() = when (snapshot.status) { - DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true - else -> false - } - val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed - val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue - val heroes: List get() { - if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { - val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty() - cachedHeroes = source.asSequence().filter { it.key !in hidden && matches(it) } - .sortedWith(compareByDescending { it.score }.thenBy { if (it.type == "movie") 0 else 1 }) - .take(if (snapshot.status == DiscoverStatus.Ready) 5 else 1).toList() - cachedHeroesRevision = revision; cachedHeroesFilter = filter - } - return cachedHeroes - } - val hero: DiscoverTitle? get() = heroes.getOrNull(slide) - val lanes: List get() { - if (cachedRevision != revision || cachedFilter != filter || cachedService != service) { - cachedLanes = buildLanes(); cachedRevision = revision; cachedFilter = filter; cachedService = service - } - return cachedLanes - } - val firstShelf: Int get() = 2 - val rowCount: Int get() = firstShelf + lanes.size - val focusedRow: Int get() = shell.view.row.coerceIn(0, (rowCount - 1).coerceAtLeast(0)) - val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf) - val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn()) - val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() } - val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true - val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null - override val cardKey: String? get() = when { - overlay != null || title != null -> null - collection != null -> collection?.cardKey - isCard -> "$rowKey:$column" - else -> null - } - - fun focus(row: Int, column: Int = 0) { - shell.navFocused = false; shell.view.row = row - shell.view.cards[rowKey] = column.coerceIn(0, maxColumn()) - } - - private fun moveRow(direction: Int) { - val target = focusedRow + direction - if (target < if (hasHero) 0 else 1) { shell.focusNav(shell.currentTab); return } - if (target >= rowCount) return - val key = when (target) { 0 -> "phero"; 1 -> "dchips"; else -> lanes[target - firstShelf].id } - val nearest = if (focusedRow >= firstShelf && target >= firstShelf) - column - shell.view.cards["first:$rowKey"].orZero() + shell.view.cards["first:$key"].orZero() - else 0 - focus(target, shell.view.cards[key] ?: nearest.coerceAtLeast(0)) - } - - fun openTitle(item: DiscoverTitle) { - title = item; titleButton = 0 - if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) - onPush("title/${android.net.Uri.encode(item.key)}") - } - fun openCollection(lane: DiscoverLane) { - var titles = lane.fullTitles.associateBy { it.key } - collectionCards = LinkedHashMap(titles.size).apply { - titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } - } - lateinit var grid: LibraryGridState - grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, - onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = { - scope.launch { - loadLane(lane.id) - if (collection !== grid) return@launch - grid.savedAt = snapshot.savedAt - val fresh = lanes.firstOrNull { it.id == lane.id } - if (canRequest && fresh != null) { - titles = fresh.fullTitles.associateBy { it.key } - collectionCards = LinkedHashMap(titles.size).apply { - titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } - } - grid.tail = when (fresh.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } - updateCollectionCards() - } else grid.tail = GridTail.Retry - } - }).apply { - savedAt = snapshot.savedAt - tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } - } - if (onMore != null && lane.hasMore) grid.pagination = LibraryPagination(grid) { - loadLane(lane.id) - val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable") - if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable") - titles = fresh.fullTitles.associateBy { it.key } - collectionCards = LinkedHashMap(titles.size).apply { - titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } - } - LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.hasMore) - } - collection = grid - onPush("discover-see-all/${lane.id}") - } - - private fun updateCollectionCards(changed: DiscoverTitle? = null) { - val grid = collection ?: return - val focused = title?.key ?: grid.focusedTitle?.id - if (changed != null && changed.key in collectionCards) collectionCards[changed.key] = changed.libraryCard(this) - grid.source = collectionCards.values.filter { it.id !in hidden } - val restored = grid.items.indexOfFirst { it.id == focused } - grid.index = if (restored >= 0) restored else grid.index.coerceIn(0, - if (grid.tail == GridTail.None) grid.items.lastIndex.coerceAtLeast(0) else grid.tailIndex) - } - fun isHidden(item: DiscoverTitle): Boolean = item.key in hidden - fun isPick(item: DiscoverTitle): Boolean = snapshot.picks.any { it.key == item.key } || isHidden(item) - - fun openService(item: DiscoverService) { - rootFilter = filter; service = item; filter = "movie"; revision++ - onPush("discover-service/${item.id}") - focus(firstShelf) - } - - fun enterRoute(route: String) { - if (route == "discover") { title = null; collection = null; if (service != null) filter = rootFilter; service = null } - else if (route.startsWith("discover-service") || route.startsWith("discover-see-all")) title = null - } - - /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ - override fun key(key: ShellKey): Boolean { - if (overlay != null) { overlayKey(key); return true } - if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false - if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } - if (title == null) collection?.let { - if (key == ShellKey.Back) { collection = null; onPop(); return true } - return it.key(key) - } - if (key == ShellKey.Back) { - if (title != null) { title = null; onPop() } - else if (service != null) { service = null; filter = rootFilter; revision++; onPop() } - else shell.focusNav(shell.currentTab) - return true - } - if (title != null) { titleKey(key); return true } - if (!hasCatalogue) { - if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() - return true - } - when (key) { - ShellKey.Up -> moveRow(-1) - ShellKey.Down -> moveRow(1) - ShellKey.Left, ShellKey.Right -> { - val direction = if (key == ShellKey.Left) -1 else 1 - if (focusedRow == 0) slide = (slide + direction).coerceIn(0, (heroes.size - 1).coerceAtLeast(0)) - else shell.view.cards[rowKey] = (column + direction).coerceIn(0, maxColumn()) - } - ShellKey.Ok -> when { - isCard -> activateCard() - focusedRow == 0 -> hero?.let(::openTitle) - focusedRow == 1 -> { filter = (if (service == null) DISCOVER_FILTERS else listOf("movie", "tv"))[column]; if (service == null) slide = 0; revision++ } - } - } - return true - } - - private fun maxColumn(): Int = when (focusedRow) { - 0 -> heroes.lastIndex - 1 -> if (service == null) 2 else 1 - else -> if (focusedLane?.id == "svc") snapshot.services.lastIndex else focusedLane?.let { it.titles.size - 1 + if (it.tail != DiscoverTail.None) 1 else 0 } ?: 0 - }.coerceAtLeast(0) - - private fun activateCard() { - val lane = focusedLane ?: return - if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return } - val item = lane.titles.getOrNull(column) - if (item == null) { - if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) } - else openCollection(lane) - } else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item) - else openTitle(item) - } - - private fun titleKey(key: ShellKey) { - if (!canRequest) { - if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() - return - } - when (key) { - ShellKey.Up -> shell.focusNav(shell.currentTab) - ShellKey.Left -> titleButton = (titleButton - 1).coerceAtLeast(0) - ShellKey.Right -> titleButton = (titleButton + 1).coerceAtMost(if (title?.let(::isPick) == true) 2 else 1) - ShellKey.Ok -> when (titleButton) { - 0 -> if (requests[requireNotNull(title).key]?.any { it.requester == shell.person.key } == true) shell.requestsVisible = true else openRequest(false) - 1 -> openRequest(true) - 2 -> title?.let { if (it.key in hidden) restore(it, showToast = true) else hide(it) } - } - else -> Unit - } - } - - fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { - if (!canRequest) { refresh(); return } - val choice = RequestSheetState(item, shell.person, requesters, other, phase) - val previous = requestSheets[item.key to choice.selectedPerson.key] - overlay = DiscoverOverlayState.Request( - if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice, - ) - } - - private fun overlayKey(key: ShellKey) { - when (val sheet = overlay) { - is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { - scope.launch { - reload() - sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable - sheet.state.focus = sheet.state.buttonIndex - } - } - is DiscoverOverlayState.Hidden -> when (key) { - ShellKey.Left, ShellKey.Up -> sheet.focus = 0 - ShellKey.Right, ShellKey.Down -> sheet.focus = 1 - ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = false) else { overlay = null; title = null; onPop() } - } - is DiscoverOverlayState.Restore -> when (key) { - ShellKey.Left, ShellKey.Up -> sheet.focus = 0 - ShellKey.Right, ShellKey.Down -> sheet.focus = 1 - ShellKey.Back -> overlay = null - ShellKey.Ok -> if (sheet.focus == 0) restore(sheet.title, showToast = true) else overlay = null - } - null -> Unit - } - } - - private fun send(sheet: RequestSheetState, check: Boolean) { - if (!canRequest || (!check && !sheet.canSubmit)) return - if (!check) { - val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] - if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { - overlay = DiscoverOverlayState.Request(requireNotNull(previous)) - return - } - } - val intent = DiscoverRequest(sheet.title, sheet.selectedPerson.key, sheet.viewer.key, isPick(sheet.title)) - requestSheets[sheet.title.key to sheet.selectedPerson.key] = sheet - sheet.phase = RequestPhase.Sending - scope.launch { - val result = (if (check) operations.check(intent) else operations.request(intent)).copy(requester = intent.requester) - sheet.result = result; sheet.phase = result.phase; sheet.focus = sheet.buttonIndex - if (result.phase == RequestPhase.Receipt) { - requests[sheet.title.key] = buildList { - requests[sheet.title.key].orEmpty().forEach { if (it.requester != result.requester) add(it) } - add(result) - } - updateCollectionCards(sheet.title) - } - } - } - - fun hide(item: DiscoverTitle) { - if (!canRequest) { refresh(); return } - hidden[item.key] = item; revision++; overlay = DiscoverOverlayState.Hidden(item) - updateCollectionCards() - val profile = shell.person.key - val version = (dismissalVersions[item.key] ?: 0) + 1 - dismissalVersions[item.key] = version - pendingDismissals[item.key] = item - scope.launch { - val success = dismissalMutex.withLock { operations.hide(profile, item) } - if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key) - if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." } - } - } - - fun restore(item: DiscoverTitle, showToast: Boolean) { - if (!canRequest) { refresh(); return } - hidden.remove(item.key); revision++; overlay = null; toast = if (showToast) "Back in your picks." else null - updateCollectionCards() - val profile = shell.person.key - val version = (dismissalVersions[item.key] ?: 0) + 1 - dismissalVersions[item.key] = version - pendingDismissals[item.key] = null - scope.launch { - val success = dismissalMutex.withLock { operations.restore(profile, item) } - if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key) - if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." } - } - } - - fun refresh() { scope.launch { reload() } } - - private suspend fun reload() { - val refreshed = operations.refresh() - val failed = refreshed.status == DiscoverStatus.Unavailable - snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed - if (!failed && hasCatalogue) savedTime = formatSavedTime() - revision++ - toast = if (failed) RecoveryMessage.StillUnreachable else null - } - private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter - private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) - - private fun buildLanes(): List { - val lanes = service?.let { - serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } - } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) - return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "More picks", emptyList(), tail = DiscoverTail.Retry) else lanes - } -} - -private fun Int?.orZero(): Int = this ?: 0 - -private val DISCOVER_FILTERS = listOf("all", "movie", "tv") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt deleted file mode 100644 index 81247975..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverTitleDestination.kt +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -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.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Explicit destination stub exposes only the Discover-owned request and pick actions. */ -@Composable -fun DiscoverTitleDestination(state: DiscoverState, modifier: Modifier = Modifier) { - val title = state.title ?: return - val focused = state.contentFocused - Box(modifier.fillMaxSize().testTag("discover-title-destination-stub")) { - DiscoverBackdrop(title, detail = true) - Column(Modifier.offset(L.safeSide, L.detailCopyTop).width(L.detailCopyWidth)) { - Text(title.title, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2) - Spacer(Modifier.height(D.heroTitleGap)) - Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()} · ${title.genres.take(2).joinToString(", ")}", color = C.muted, style = WolfskinTokens.Type.body) - Spacer(Modifier.height(D.heroMetaGap)) - if (state.isSignedIn) DiscoverRatings(title.ratings) - Spacer(Modifier.height(D.heroMetaGap)) - Text(title.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3) - Spacer(Modifier.height(D.heroOverviewGap)) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - if (!state.canRequest) DiscoverButton("Try again", focused) { state.refresh() } else { - val receipt = state.requests[title.key]?.firstOrNull { it.requester == state.shell.person.key } - DiscoverButton(if (receipt == null) "Request" else "Requested · ${receipt.stage}", focused && state.titleButton == 0, glyph = if (receipt == null) "plus" else "clock") { - state.titleButton = 0; state.key(ShellKey.Ok) - } - DiscoverButton(if (state.shell.person.key in title.heldBy) "Add for someone" else "For someone else", focused && state.titleButton == 1, glyph = "profile", primary = false) { state.openRequest(true) } - if (state.isPick(title)) DiscoverButton(if (state.isHidden(title)) "Show again" else "Not interested", focused && state.titleButton == 2, glyph = if (state.isHidden(title)) "undo" else "hide", primary = false) { state.titleButton = 2; state.key(ShellKey.Ok) } - } - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt deleted file mode 100644 index 45df9669..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverType.kt +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.FirstBaseline -import androidx.compose.ui.layout.LastBaseline -import androidx.compose.ui.layout.layout -import androidx.compose.ui.text.TextStyle -import kotlin.math.roundToInt -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineHeightStyle - -/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */ -internal object DiscoverType { - private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) - private val platform = PlatformTextStyle(includeFontPadding = false) - val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform) - val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform) - val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform) - val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform) - val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform) - val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform) - val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform) - val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform) - val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform) - val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform) - val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform) - val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform) -} - -/** Paragraph overflow must not expand the CSS block height. */ -internal fun Modifier.discoverLineBox(style: TextStyle): Modifier = layout { measurable, constraints -> - val paragraph = measurable.measure(constraints) - val lineHeight = style.lineHeight.toPx() - val lines = ((paragraph[LastBaseline] - paragraph[FirstBaseline]) / lineHeight).roundToInt() + 1 - val height = (lines * lineHeight).roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight) - layout(paragraph.width, height) { - paragraph.placeRelative(0, (height - paragraph.height) / 2) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt deleted file mode 100644 index 39e23ea1..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.remember -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.Dp - -/** Only transforms animate: focus never changes measured geometry. */ -@Composable -fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { - val animated by animateFloatAsState( - if (focused) scale else 1f, - tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), - label = "focus-scale", - ) - return graphicsLayer { scaleX = animated; scaleY = animated } -} - -/** Lift shadows remain specific to cards and buttons. */ -enum class FocusLift { None, Card, Button } - -/** Every focus ring straddles the surface edge above content, without a background gap. */ -@Composable -fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { - val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, - tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") - val shadow = if (lift == FocusLift.None) this else { - val shape = remember(radius) { RoundedCornerShape(radius) } - val density = LocalDensity.current - val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() } - val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() } - dropShadow(shape) { - this.radius = blur - offset = Offset(0f, shift) - this.color = Color.Black - alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity - } - } - return shadow.drawWithCache { - val stroke = Stroke(width.toPx()) - val corner = CornerRadius(radius.toPx()) - onDrawWithContent { - drawContent() - if (ringAlpha > 0f) { - drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha), - cornerRadius = corner, style = stroke) - } - } - } -} - -/** Pointing changes the same logical focus as the D-pad without activating an action. */ -@Composable -fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier { - val action by rememberUpdatedState(onEnter) - val interactions = remember { MutableInteractionSource() } - val hovered by interactions.collectIsHoveredAsState() - LaunchedEffect(hovered) { if (hovered) action() } - return hoverable(interactions) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt deleted file mode 100644 index 18b83e5f..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween -import androidx.compose.foundation.lazy.LazyListState - -/** Every frame follows focus with the same bounded scroll tween. */ -internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { - if (distance == 0f) return - list.scroll { - var previous = 0f - animate(0f, distance, animationSpec = tween(WolfskinTokens.Motion.scroll, - easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) { value, _ -> - scrollBy(value - previous) - previous = value - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt deleted file mode 100644 index 5a9227d3..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.tv.material3.Icon - -/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */ -@Composable -fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { - val resource = when (name) { - "gear" -> R.drawable.wolfskin_gear - "requests" -> R.drawable.wolfskin_tray - "interface" -> R.drawable.wolfskin_grid - "play", "playback" -> R.drawable.wolfskin_play - "tv", "nextup" -> R.drawable.wolfskin_tv - "users", "profile" -> R.drawable.wolfskin_users - "about" -> R.drawable.wolfskin_info - "sparkle", "seerr" -> R.drawable.wolfskin_sparkle - "advanced" -> R.drawable.wolfskin_sort - "alert" -> R.drawable.wolfskin_alert - "wifi" -> R.drawable.wolfskin_wifi - "clock" -> R.drawable.wolfskin_clock - "plus" -> R.drawable.wolfskin_plus - "check" -> R.drawable.wolfskin_check - "hide" -> R.drawable.wolfskin_hide - "undo" -> R.drawable.wolfskin_undo - "left" -> R.drawable.wolfskin_left - "download" -> R.drawable.wolfskin_download - "film" -> R.drawable.wolfskin_film - "eye" -> R.drawable.wolfskin_eye - "down" -> R.drawable.wolfskin_down - "restart" -> R.drawable.title_restart - "version" -> R.drawable.title_version - "tracks" -> R.drawable.title_tracks - "eyeoff" -> R.drawable.title_eyeoff - "trailer" -> R.drawable.title_trailer - "more" -> R.drawable.title_more - else -> R.drawable.wolfskin_right - } - Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt deleted file mode 100644 index 7f6b300a..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeAmbient.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.graphics.Matrix -import android.graphics.RadialGradient -import android.graphics.Shader -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.toArgb - -/** Empty hero art keeps the locked elliptical ambient wash rather than flashing a black frame. */ -@Composable -fun HomeAmbient(modifier: Modifier = Modifier) { - val brush = remember { - object : ShaderBrush() { - override fun createShader(size: Size): Shader { - val centerX = size.width * .7f - val centerY = size.height * .2f - val radiusX = size.width * 1.2f - val radiusY = size.height * .9f - return RadialGradient(centerX, centerY, radiusX, intArrayOf(WolfskinTokens.Color.surface3.toArgb(), WolfskinTokens.Color.bg.toArgb()), null, Shader.TileMode.CLAMP).apply { - setLocalMatrix(Matrix().apply { setScale(1f, radiusY / radiusX, centerX, centerY) }) - } - } - } - } - Box(modifier.background(brush)) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt deleted file mode 100644 index e91b22cd..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.justinvisser.wolfskin.design - -import kotlin.math.abs - -/** Home navigation mutates the shell's per-person view, including each row's remembered column. */ -class HomeFocus( - private val shell: ShellState, - private val rows: List, - private val onAction: (HomeAction) -> Unit, -) : ShellContentFocus { - private val view = shell.rememberedView("home") - override val hasContent: Boolean get() = rows.any { it.focusable } - override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null } - val row: HomeRow? get() = rows.getOrNull(view.row) - val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0 - - fun focus(index: Int, column: Int) { - shell.navFocused = false - view.row = index - view.home.focusedKey = rows[index].id - val target = rows[index] - val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0)) - view.cards[target.id] = bounded - if (target.kind == HomeRowKind.Shelf) view.home.firstCards[target.id] = target.adjustedFirst(bounded, view.home.firstCards[target.id] ?: 0) - } - - override fun key(key: ShellKey): Boolean { - val current = row ?: return false - if (key == ShellKey.Back) return false - if (key == ShellKey.Ok) { activate(current); return true } - if (reenterVisible()) return true - when (key) { - ShellKey.Left -> focus(view.row, (column - 1).coerceAtLeast(0)) - ShellKey.Right -> focus(view.row, (column + 1).coerceAtMost((current.count - 1).coerceAtLeast(0))) - ShellKey.Up, ShellKey.Down -> { - val direction = if (key == ShellKey.Up) -1 else 1 - var next = view.row + direction - while (next in rows.indices && !rows[next].focusable) next += direction - if (next in rows.indices) { - val target = rows[next] - val nearest = nearest(current, target) - focus(next, view.cards[target.id] ?: nearest) - } else if (direction < 0) { - view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) - view.home.resetScroll++ - shell.navFocused = true - shell.navIndex = shell.currentTab - } - } - } - return true - } - - private fun reenterVisible(): Boolean { - val layout = view.listState.layoutInfo - if (layout.totalItemsCount == 0 || view.home.positionedKey != row?.id || view.listState.isScrollInProgress) return false - val density = layout.viewportSize.width / WolfskinTokens.Shell.stageWidth.value - val current = layout.visibleItemsInfo.firstOrNull { it.index == view.row } - if (current != null && current.offset + current.size >= WolfskinTokens.Layout.navSolidAfter.value * density && - current.offset <= layout.viewportSize.height - WolfskinTokens.Layout.rowHeadingBand.value * 2 * density) return false - val first = layout.visibleItemsInfo.firstOrNull { - rows[it.index].focusable && it.offset + it.size > WolfskinTokens.Layout.rowHeadingBand.value * density && - it.offset < layout.viewportSize.height - WolfskinTokens.Layout.row_endcta.value * density - } ?: return false - focus(first.index, view.cards[rows[first.index].id] ?: 0) - return true - } - - private fun activate(row: HomeRow) { - when (row.kind) { - HomeRowKind.Hero -> row.cards.firstOrNull()?.let { title -> - onAction(if (column == 0 && row.heroKind != "pick") HomeAction.Play(title, row.heroKind == "resume") else HomeAction.Details(title)) - } - HomeRowKind.Shelf -> if (column < row.cards.size) onAction(HomeAction.Details(row.cards[column])) else onAction(HomeAction.SeeAll(row)) - HomeRowKind.State -> onAction(HomeAction.Retry) - HomeRowKind.Explore -> onAction(HomeAction.Discover) - else -> Unit - } - } - - private fun nearest(from: HomeRow, to: HomeRow): Int { - if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt() - val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 - val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 - val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0)) - val last = minOf(to.count - 1, first + to.focusSlots - 1) - var best = first - var distance = Float.POSITIVE_INFINITY - for (index in first..last) { - val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) - if (candidate < distance) { best = index; distance = candidate } - } - return best - } - - private fun pitch(row: HomeRow): Float = when (row.cardKind) { - HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value - HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value - HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value - } -} - -internal val HomeRow.focusSlots: Int get() = when (cardKind) { - HomeCardKind.Poster -> 6 - HomeCardKind.Tile -> 4 - HomeCardKind.Wide -> 3 -} - -/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */ -internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int { - var first = previous - if (column > first + focusSlots - 2) first = column - (focusSlots - 2) - if (column < first + 1) first = (column - 1).coerceAtLeast(0) - return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0)) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt deleted file mode 100644 index eb487f62..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.HomeType as T - -/** The hero occupies one row while its backdrop extends beneath the first shelves. */ -@Composable -fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { - Box(Modifier.fillMaxWidth().height(L.row_hero)) { - if (!row.skeleton) { - val title = row.cards.first() - val artModifier = Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight) - TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight, artModifier, backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = true, hero = true) - Box(artModifier.background(Brush.verticalGradient(0f to C.bg.copy(alpha = 0f), .52f to C.bg.copy(alpha = 0f), .74f to C.bg.copy(alpha = .6f), 1f to C.bg))) - Box(artModifier.background(Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f)))) - Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom).width(L.heroCopyWidth)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.kickerGap)) { - Glyph(if (row.heroKind == "pick") "seerr" else "playback", C.accentText, Modifier.size(L.buttonIcon * (28f / 30f))) - Text(when (row.heroKind) { "resume" -> "Continue watching"; "next" -> "Up next"; "new" -> "New in your library"; else -> "For you" }, modifier = Modifier.exactLineBox(T.label), color = C.accentText, style = T.label) - } - Spacer(Modifier.height(H.kickerBottom)) - Text(title.displayTitle, modifier = Modifier.exactLineBox(T.displayHero), color = C.fg, style = T.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.height(H.titleBottom)) - Row(Modifier.heightIn(min = H.factsHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.factsGap)) { - Text(if (title.isEpisode) title.subtitle else title.year?.toString().orEmpty(), modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) - if (row.heroKind == "resume" || title.runtimeSeconds != null) Text(if (row.heroKind == "resume") title.remaining else title.runtime, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body, maxLines = 1) - HomeRatingsRow(ratings) - } - Spacer(Modifier.height(H.factsBottom)) - Text(title.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body)) - Spacer(Modifier.height(H.overviewBottom)) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else "" - PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) } - if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) } - } - } - } else { - HomeAmbient(Modifier.wrapContentSize(Alignment.TopStart, unbounded = true).requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) - Column(Modifier.align(Alignment.BottomStart).padding(start = L.safeSide, bottom = L.heroCopyBottom)) { - Box(Modifier.size(H.heroSkeletonWidth, H.heroSkeletonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) - Spacer(Modifier.height(H.heroSkeletonGap)) - Box(Modifier.size(H.heroSkeletonFactsWidth, H.heroSkeletonFactsHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) - Spacer(Modifier.height(H.heroSkeletonButtonGap)) - Box(Modifier.size(H.heroSkeletonButtonWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) - } - } - } -} - -/** Discover continues in the same scroller, under the active person's identity. */ -@Composable -fun HomeSection(person: ShellPerson, savedAt: String?) { - Box(Modifier.fillMaxWidth().height(L.row_section)) { - Row(Modifier.padding(start = L.safeSide, top = H.sectionTop), horizontalArrangement = Arrangement.spacedBy(H.sectionGap), verticalAlignment = Alignment.CenterVertically) { - Text("Discover", color = C.fg, style = T.sectionTitle) - Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader) - Text("Picks for you first, then what’s out there", color = C.muted, style = T.body) - savedAt?.let { SavedPill(it) } - } - } -} - -/** Failures keep a focusable recovery action without covering working library shelves. */ -@Composable -fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) { - Box(Modifier.fillMaxWidth().height(L.row_state)) { - Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX + H.hairline, vertical = H.statePaddingY + H.hairline)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) { - Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon)) - Text(row.title, modifier = Modifier.exactLineBox(T.stateTitle), color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold)) - } - Spacer(Modifier.height(H.stateGap)) - Text(row.text, modifier = Modifier.exactLineBox(T.body), color = C.muted, style = T.body) - Spacer(Modifier.height(H.stateTextBottom)) - PickerButton("Try again", focused, row.calm, onFocus = onHover, textStyle = T.button, onClick = retry) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt deleted file mode 100644 index d2e6adb2..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Immutable -import java.util.Locale -import kotlin.math.roundToInt - -/** A screen-owned media projection, independent of Jellyfin and catalogue DTOs. */ -@Immutable -data class HomeTitle( - val id: String, - val title: String, - val type: String, - val year: Int? = null, - val runtimeSeconds: Int? = null, - val seasons: Int? = null, - val overview: String = "", - val poster: String? = null, - val backdrop: String? = null, - val posterOnly: Boolean = false, - val seriesTitle: String? = null, - val season: Int? = null, - val episode: Int? = null, - val resumeSeconds: Double = 0.0, - val played: Boolean = false, - val addedAt: String = "", - val genres: List = emptyList(), - val ratings: List = emptyList(), - val held: Boolean = false, - val otherOwner: String? = null, - val tmdbId: Int? = null, -) { - val isEpisode: Boolean get() = type == "episode" - val displayTitle: String get() = seriesTitle ?: title - val episodeLabel: String get() = "S$season E$episode" - val runtime: String get() { - val minutes = ((runtimeSeconds ?: 0) / 60.0).roundToInt() - return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" - } - val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" - val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}" - val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ") -} - -/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */ -@Immutable -data class HomeRating(val mark: String, val score: Double, val percentage: Boolean = false, val episode: Boolean = false) { - fun format(locale: Locale = Locale.getDefault()): String = if (percentage) "${score.toInt()}%" else String.format(locale, "%.1f", score) -} - -enum class HomeCardKind { Poster, Tile, Wide } -enum class HomeRowKind { Hero, Shelf, Section, State, Note, Explore } -enum class HomeAvailability { Ready, Loading, SignedOut, Unavailable, Saved } - -/** Rows retain stable keys while a refresh replaces their immutable contents. */ -@Immutable -data class HomeRow( - val id: String, - val kind: HomeRowKind, - val title: String = "", - val cards: List = emptyList(), - val cardKind: HomeCardKind = HomeCardKind.Poster, - val catalogue: Boolean = false, - val total: Int = cards.size, - val target: String? = null, - val text: String = "", - val heroKind: String = "next", - val skeleton: Boolean = false, - val calm: Boolean = false, - val allCards: List = cards, -) { - val hasSeeAll: Boolean get() = total > cards.size - val count: Int get() = when (kind) { HomeRowKind.Hero -> if (heroKind == "pick") 1 else 2; HomeRowKind.Shelf -> cards.size + if (hasSeeAll) 1 else 0; HomeRowKind.State, HomeRowKind.Explore -> 1; else -> 0 } - val focusable: Boolean get() = !skeleton && count > 0 -} - -/** One snapshot paints immediately; source status never discards a saved view. */ -@Immutable -data class HomeSnapshot( - val rows: List, - val conductarr: HomeAvailability = HomeAvailability.SignedOut, - val jellyfinAvailable: Boolean = true, - val savedAt: String? = null, -) - -/** Home emits integration intents, never owns the upstream player or later-slice pages. */ -sealed interface HomeAction { - data class Play(val title: HomeTitle, val resume: Boolean) : HomeAction - data class Details(val title: HomeTitle) : HomeAction - data class SeeAll(val row: HomeRow) : HomeAction - data object Discover : HomeAction - data object Retry : HomeAction -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt deleted file mode 100644 index d36d0f85..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatingCache.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.justinvisser.wolfskin.design - -/** The screen's ratings cache uses an injected clock and never caches failed reads. */ -class HomeRatingCache(private val clock: () -> Long = System::currentTimeMillis) { - private data class Entry(val ratings: List, val readAt: Long) - private val entries = linkedMapOf() - private val seasons = linkedMapOf>() - - fun read(title: HomeTitle): List? { - val season = seasonKey(title) - val bucket = season?.let { seasons[it] } - val entry = if (season != null) bucket?.get(title.episode) else entries[key(title)] - if (entry == null) return null - if (clock() - entry.readAt >= lifetimeMillis) { - if (season == null) entries.remove(key(title)) else { - bucket?.remove(title.episode) - if (bucket.isNullOrEmpty()) seasons.remove(season) - } - return null - } - return entry.ratings - } - - fun save(title: HomeTitle, ratings: List) { - val entry = Entry(ratings, clock()) - val season = seasonKey(title) - if (season != null) { - val bucket = seasons.remove(season) ?: mutableMapOf() - bucket[requireNotNull(title.episode)] = entry - seasons[season] = bucket - while (seasons.size > capacity) seasons.remove(seasons.keys.first()) - } else { - val key = key(title) - entries.remove(key) - entries[key] = entry - while (entries.size > capacity) entries.remove(entries.keys.first()) - } - } - - private fun key(title: HomeTitle): String = if (title.isEpisode) "episode:${title.id}" else "${title.type}:${title.tmdbId ?: title.id}" - - private fun seasonKey(title: HomeTitle): String? = - if (title.isEpisode && title.tmdbId != null && title.season != null && title.episode != null) - "${title.tmdbId}:${title.season}" else null - - companion object { - private const val capacity = 300 - private val lifetimeMillis = (WolfskinTokens.Behaviour.cacheRatingsHours * 60 * 60 * 1000).toLong() - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt deleted file mode 100644 index 669ea07c..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt +++ /dev/null @@ -1,92 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.TextUnit -import androidx.tv.material3.Text -import java.util.Locale -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.HomeType as T - -/** Formatting is measured before fitting, so decimal commas obey the same one-line rule. */ -@Composable -fun HomeRatingsRow(ratings: List, modifier: Modifier = Modifier, fontSize: TextUnit = T.ratingPlate.fontSize, plate: Boolean = false) { - val density = LocalDensity.current - val size = with(density) { fontSize.toDp() } - val entryGap = if (plate) size * .64f else H.ratingGap - val markGap = if (plate) size * .29f else H.ratingMarkGap - Row(modifier, horizontalArrangement = Arrangement.spacedBy(entryGap), verticalAlignment = Alignment.CenterVertically) { - ratings.forEach { rating -> - Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { - val resource = when (rating.mark) { - "rotten" -> R.drawable.ic_rotten_tomatoes_rotten - "upright" -> R.drawable.ic_rt_upright - "popcorn" -> R.drawable.ic_rt_upright - "spilled" -> R.drawable.ic_rt_spilled - "imdb" -> R.drawable.ic_rating_imdb - "tmdb" -> R.drawable.ic_rating_tmdb - else -> R.drawable.ic_rotten_tomatoes_fresh - } - val muted = rating.mark == "tomato" || rating.mark == "popcorn" - Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) - if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1) - Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false) - } - } - } -} - -/** The plate is a prefix of the full ratings set; its first entry is never discarded. */ -@Composable -fun HomeRatingPlate(ratings: List, visible: Boolean, modifier: Modifier = Modifier) { - val alpha by animateFloatAsState(if (visible && ratings.isNotEmpty()) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") - if (ratings.isEmpty()) return - val measurer = rememberTextMeasurer() - val density = LocalDensity.current - val locale = Locale.getDefault() - BoxWithConstraints(modifier.graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(0f to C.bg.copy(alpha = .9f), .52f to C.bg.copy(alpha = .72f), 1f to C.bg.copy(alpha = 0f))).padding(start = H.plateSide, end = H.plateSide, top = H.plateTop, bottom = H.plateFade)) { - val available = with(density) { maxWidth.toPx() } - val fitted = remember(ratings, available, density, locale) { - val base = T.ratingPlate.fontSize - val floor = T.caption.fontSize - fun width(size: TextUnit, entries: List): Float { - val em = with(density) { size.toPx() } - return entries.sumOf { rating -> - val mark = em * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f - val number = measurer.measure(rating.format(locale), T.ratingPlate.copy(fontSize = size)).size.width - val episode = if (rating.episode) measurer.measure("Ep", T.ratingPlate.copy(fontSize = size, fontWeight = T.label.fontWeight)).size.width + em * .29f else 0f - (mark + em * .29f + number + episode).toDouble() - }.toFloat() + (entries.size - 1).coerceAtLeast(0) * em * .64f - } - var size = base - val initialWidth = width(base, ratings) - if (initialWidth > available) size = base * (available / initialWidth).coerceAtLeast(floor.value / base.value) - val step = (base.value - floor.value) / 8 - while (size > floor && width(size, ratings) > available) size = base * ((size.value - step).coerceAtLeast(floor.value) / base.value) - var count = ratings.size - while (count > 1 && width(size, ratings.take(count)) > available) count-- - size to ratings.take(count) - } - HomeRatingsRow(fitted.second, fontSize = fitted.first, plate = true) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt deleted file mode 100644 index 4c7f8beb..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.justinvisser.wolfskin.design - -/** Source reads preserve their order; the hero is removed by stable item identity. */ -data class HomeLibrary( - val resume: List = emptyList(), - val nextUp: List = emptyList(), - val newest: List = emptyList(), - val destinations: Map> = emptyMap(), - val destinationTotals: Map = emptyMap(), -) - -/** Merge the two sources into the single Home frame without inventing playable items. */ -fun buildHomeRows( - library: HomeLibrary, - continuation: List, - libraryAvailable: Boolean = true, - loading: Boolean = false, -): List = buildList { - if (loading) { - add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true)) - listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind -> - add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true)) - } - return@buildList - } - val choice = when { - library.resume.isNotEmpty() -> "resume" to library.resume.first() - library.nextUp.isNotEmpty() -> "next" to library.nextUp.first() - library.newest.isNotEmpty() -> "new" to library.newest.first() - else -> continuation.firstNotNullOfOrNull { it.cards.firstOrNull() }?.let { "pick" to it } - } - val hero = choice?.second - if (hero != null) add(HomeRow("hero", HomeRowKind.Hero, cards = listOf(hero), heroKind = choice.first)) - if (!libraryAvailable) { - add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now.")) - } else { - listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) -> - val cards = source.filter { it.id != hero?.id } - if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile)) - } - listOf("movies", "shows", "anime").forEach { destination -> - val titles = library.destinations[destination].orEmpty().sortedWith(compareBy { it.played }.thenByDescending { it.addedAt }) - if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination)) - } - } - add(HomeRow("disc-h", HomeRowKind.Section, "Discover")) - addAll(continuation) -} - -/** Authentication failure is distinct from an unreachable signed-in catalogue. */ -fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) { - HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true) -} else { - HomeRow("cond-down", HomeRowKind.State, "Discover is resting", text = "Conductarr can’t be reached. Your library still plays.") -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt deleted file mode 100644 index 84766c0e..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.tv.material3.Text -import kotlinx.coroutines.delay -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** One collector owns the focused card dwell; cached scores appear on re-entry without another read. */ -@Composable -fun HomeScreen( - shell: ShellState, - snapshot: HomeSnapshot, - modifier: Modifier = Modifier, - onAction: (HomeAction) -> Unit, -) { - val view = shell.rememberedView("home") - LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt } - val action by rememberUpdatedState(onAction) - val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } } - DisposableEffect(shell, focus) { - shell.contentFocus = focus - onDispose { if (shell.contentFocus === focus) shell.contentFocus = null } - } - LaunchedEffect(snapshot.rows, view) { - snapshot.rows.forEach { item -> - view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) } - } - val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable } - if (remembered >= 0) view.row = remembered - if (snapshot.rows.none { it.focusable }) { - if (view.home.focusedKey == null && !shell.navFocused) view.home.loadingEntryRevision = shell.inputRevision - shell.navFocused = true - shell.navIndex = shell.currentTab - } else { - if (view.home.loadingEntryRevision == shell.inputRevision) focus.focus(snapshot.rows.indexOfFirst { it.focusable }, 0) - view.home.loadingEntryRevision = null - if (snapshot.rows.getOrNull(view.row)?.focusable != true) view.row = snapshot.rows.indexOfFirst { it.focusable } - } - view.home.focusedKey = snapshot.rows.getOrNull(view.row)?.id - } - val density = LocalDensity.current - val heights = remember(snapshot.rows) { snapshot.rows.map { rowHeight(it) } } - val topInset = if (snapshot.rows.firstOrNull()?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine - val context = LocalContext.current - LaunchedEffect(view.row, snapshot.rows) { - snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } - } - LaunchedEffect(view) { - if (view.home.positionedKey != null) { - view.home.focusedKey?.let { view.home.tracks[it] }?.let { track -> - track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) - } - view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset) - } - } - LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) { - val key = snapshot.rows.getOrNull(view.row)?.id - if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect - val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll - val target = if (topRequested) 0f else with(density) { - (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() - }.coerceAtLeast(0f) - view.home.positionedKey = key - view.home.positionedReset = view.home.resetScroll - val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset - view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) - } - val cache = view.home.ratings - val ratingsReader = LocalScreenRatings.current - val row = snapshot.rows.getOrNull(view.row) - val title = row?.cards?.getOrNull(focus.column) - val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull() - var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) } - var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } - LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { - val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved - coroutineScope { - if (hero != null && available) launch { - val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) } - if (values != null) { heroRatings = values; cache.save(hero, values) } - } - if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch { - val saved = cache.read(title) - if (saved == null) delay(WolfskinTokens.Motion.ratingDwell.toLong()) - val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) } - if (values != null) { focusedRatings = values; cache.save(title, values) } - } - } - } - val showRatings = snapshot.conductarr != HomeAvailability.SignedOut - val trailingSpace = maxOf(H.frameBottom, WolfskinTokens.Shell.stageHeight - L.pinnedLine - (heights.lastOrNull() ?: WolfskinTokens.Shell.zero)) - LazyColumn(state = view.listState, modifier = modifier.testTag("wolfskin-home").semantics { stateDescription = "${row?.id}:${focus.column}" }, contentPadding = PaddingValues(top = topInset, bottom = trailingSpace)) { - itemsIndexed(snapshot.rows, key = { _, item -> item.id }, contentType = { _, item -> item.kind.name.lowercase() }) { index, item -> - val selected = index == view.row && !shell.navFocused - fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok) } - when (item.kind) { - HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) } - HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } - HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt) - HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) } - HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) { - Glyph(if (item.calm) "eye" else "alert", if (item.calm) C.muted else C.warn, Modifier.size(L.buttonIcon)) - Text(item.text, color = if (item.calm) C.muted else C.warn, style = HomeType.body) - } - HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) { - PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, textStyle = HomeType.button) { activate(0) } - } - } - } - } -} - -private fun rowHeight(row: HomeRow) = when (row.kind) { - HomeRowKind.Hero -> L.row_hero - HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter - HomeRowKind.Section -> L.row_section - HomeRowKind.State -> L.row_state - HomeRowKind.Note -> L.row_note - HomeRowKind.Explore -> L.row_endcta -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt deleted file mode 100644 index 9b97fe8b..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.shape.CircleShape -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.remember -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.collectLatest -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.zIndex -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.HomeType as T - -/** Every lazy row and card uses a stable source identity and an explicit content type. */ -@Composable -fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { - val width = row.cardWidth - val height = row.cardHeight - val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero - val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) - SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } - val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } - val targetFirst by rememberUpdatedState(first) - val density = LocalDensity.current - LaunchedEffect(track) { - track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) - var positionedFirst = targetFirst - snapshotFlow { targetFirst }.collectLatest { target -> - if (target != positionedFirst) { - positionedFirst = target - val pitch = with(density) { (width + K.posterGap).toPx() } - track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base)) - } - } - } - Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { - if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) - val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { - itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> - val selected = focused && column == index - val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") - .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) - if (title == null && !row.skeleton) { - LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) - } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { - if (title == null) Box(Modifier.fillMaxSize().background(C.surface)) else { - TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) - if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) - if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } - if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { - Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent)) - } - HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) - } - } - } - } - if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { - val title = row.cards.getOrNull(column) - val availability = when { - title == null -> null - row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } - title.played -> "Watched" - title.resumeSeconds > 0 -> title.remaining - else -> null - } - val facts = remember(title, row.total, availability) { - (title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty() - } - Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis) - } - } -} - -@Composable -private fun TileOverlay(title: HomeTitle) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { - Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { - Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } -} - -val HomeRow.cardWidth: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterW; HomeCardKind.Tile -> K.tileW; HomeCardKind.Wide -> K.wideW } -val HomeRow.cardHeight: Dp get() = when (cardKind) { HomeCardKind.Poster -> K.posterH; HomeCardKind.Tile -> K.tileH; HomeCardKind.Wide -> K.wideH } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt deleted file mode 100644 index f54faeaa..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeType.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.text.PlatformTextStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.LineHeightStyle - -/** Home keeps full CSS line boxes without moving retained shell or library typography. */ -object HomeType { - private fun fullBox(style: TextStyle): TextStyle = style.copy( - platformStyle = PlatformTextStyle(includeFontPadding = false), - lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None, LineHeightStyle.Mode.Tight), - ) - val body = fullBox(WolfskinTokens.Type.body) - val button = fullBox(WolfskinTokens.Type.button) - val caption = fullBox(WolfskinTokens.Type.caption) - val label = fullBox(WolfskinTokens.Type.label) - val displayHero = fullBox(WolfskinTokens.Type.displayHero) - val stateTitle = fullBox(WolfskinTokens.Type.stateTitle) - val sectionTitle = fullBox(WolfskinTokens.Type.sectionTitle) - val rowHeading = WolfskinTokens.Type.rowHeading - val ratingPlate = WolfskinTokens.Type.ratingPlate - val listTitle = fullBox(WolfskinTokens.Type.listTitle) - val avatarLetterSectionHeader = WolfskinTokens.Type.avatarLetterSectionHeader -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt deleted file mode 100644 index 65f379ad..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeViewMemory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.setValue - -/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */ -class HomeViewMemory { - val ratings = HomeRatingCache() - val firstCards = mutableStateMapOf() - val tracks = mutableMapOf() - var loadingEntryRevision: Long? = null - var focusedKey: String? = null - var positionedKey: String? = null - var resetScroll by mutableIntStateOf(0) - var positionedReset = -1 -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt deleted file mode 100644 index 266cbded..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.tv.material3.Text - -/** Fixed household colours never depend on preference or avatar presets. */ -fun personColor(key: String): Color = when (key.lowercase()) { - "justin" -> WolfskinTokens.PersonColor.justin - "mindel" -> WolfskinTokens.PersonColor.mindel - "shared" -> WolfskinTokens.PersonColor.shared - else -> WolfskinTokens.Color.accent -} - -/** The same mark anchors the picker and the persistent navigation. */ -@Composable -fun Wordmark(modifier: Modifier = Modifier) { - Row(modifier) { - Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) - Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) - } -} - -/** Initials are drawn over opaque person colour at the exact slot size. */ -@Composable -fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) { - Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) { - Text(person.initial, color = letterColor, style = textStyle) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt deleted file mode 100644 index 24f680c3..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryFixture.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.justinvisser.wolfskin.design - -import org.json.JSONArray -import org.json.JSONObject - -/** The locked fixture is supplied by debug assets or JVM resources, never a release fallback. */ -class LibraryFixture(json: String, private val posterPrefix: String) { - private val document = JSONObject(json) - private val profiles = document.getJSONArray("profiles") - private val people = (0 until profiles.length()).associate { profiles.getJSONObject(it).let { person -> person.getString("key") to person } } - private val holders = buildMap> { - people.forEach { (key, person) -> - for (destination in listOf("movies", "shows", "anime")) { - val library = person.optJSONArray(destination) ?: continue - for (index in 0 until library.length()) { - val item = library.getJSONObject(index) - getOrPut("${item.optString("type")}:${item.optString("tmdbId")}") { mutableListOf() }.add(key.replaceFirstChar { it.uppercase() }) - } - } - } - } - - fun library(person: String, destination: String): List = readTitles(people.getValue(person).optJSONArray(destination) ?: JSONArray()) - fun picks(person: String, type: String): List = readTitles(people.getValue(person).getJSONArray("discover")).filter { it.type == type } - fun trending(): List = readTitles(document.getJSONObject("seeAll").getJSONArray("trending")) - fun duplicatePage(): List = readTitles(document.getJSONObject("seeAll").getJSONArray("duplicate")) - - private fun readTitles(array: JSONArray): List = List(array.length()) { index -> - val item = array.getJSONObject(index) - val type = item.getString("type") - val ratings = item.optJSONObject("ratings") - val owners = holders["$type:${item.optString("tmdbId")}"].orEmpty() - val mine = item.optString("availability") == "in_library" - LibraryTitle( - id = item.getString("id"), title = item.getString("title"), type = type, - year = item.optInt("year").takeIf { it > 0 }, runtimeSeconds = item.optLong("runtimeSeconds").takeIf { it > 0 }, - seasons = item.optInt("seasonCount").takeIf { it > 0 }, - poster = item.optString("poster").takeIf { it.isNotBlank() && it != "null" }?.let { posterPrefix + it }, - played = item.optBoolean("played"), progress = item.optDouble("progress", 0.0).toFloat(), - addedAt = item.optString("addedAt"), ratings = buildList { - fun append(source: String, field: String, label: String? = null) { - val score = ratings?.optDouble(field, Double.NaN) ?: Double.NaN - val maximum = if (source.startsWith("rt")) 100 else 10 - if (score.isFinite() && score in 0.0..maximum.toDouble()) add(LibraryRating(source, score, label)) - } - append("rtCritics", "rtCritics", ratings?.optString("rtCriticsLabel")) - append("rtAudience", "rtAudience", ratings?.optString("rtAudienceLabel")) - if (type == "movie" && (ratings?.optInt("imdbVotes", 0) ?: 0) > 0) append("imdb", "imdb") - if (isEmpty() && (ratings?.optInt("tmdbVotes", 0) ?: 0) > 0) append("tmdb", "tmdb") - }, - availability = if (mine) "In your library" else if (owners.isNotEmpty()) "In ${owners.joinToString(" and ")}’s library" else "", - badge = if (mine) "held" else null, - tmdbId = item.optString("tmdbId"), - ) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt deleted file mode 100644 index 2b6bcc1a..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.derivedStateOf -import java.util.Locale -import androidx.compose.runtime.Immutable - -private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE) - -/** Art-only card data shared without per-card adapter allocations. */ -@Immutable -interface PosterContent { - val id: String - val posterTitle: String - val posterIsMovie: Boolean - val poster: String? - val ratings: List - val progress: Float - val badge: String? -} - -/** Immutable title facts cross the provider boundary without SDK objects. */ -data class LibraryTitle( - override val id: String, - val title: String, - val type: String, - val year: Int? = null, - val runtimeSeconds: Long? = null, - val seasons: Int? = null, - override val poster: String? = null, - val played: Boolean = false, - override val progress: Float = 0f, - val addedAt: String = "", - override val ratings: List = emptyList(), - val availability: String = "", - override val badge: String? = null, - val tmdbId: String? = null, -) : PosterContent { - override val posterTitle: String get() = title - override val posterIsMovie: Boolean get() = type == "movie" - val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) - val catalogueKey: String = "$type:${tmdbId ?: id}" - val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' - val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 - - fun facts(catalogue: Boolean): String = buildList { - year?.let { add(it.toString()) } - add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series") - if (catalogue) { if (availability.isNotEmpty()) add(availability) } - else if (played) add("Watched") - else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left") - }.joinToString(" · ") -} - -/** Ordered rating entries are formatted after the active TV locale is known. */ -@Immutable -data class LibraryRating(val source: String, val score: Double, val label: String? = null, val episode: Boolean = false) { - fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score) -} - -enum class LibraryStatus { Ready, Loading, Unavailable } -enum class GridTail { None, Retry, ShowMore } - -/** A grid retains its exact card, toolbar choices and rail return point with the shell view. */ -class LibraryGridState( - val title: String, - items: List, - val catalogue: Boolean = false, - val onTitle: (LibraryTitle) -> Unit = {}, - val onRecover: () -> Unit = {}, - val onDiscover: () -> Unit = {}, - val onReadAhead: (Int) -> Unit = {}, - val observedRatings: MutableSet = mutableSetOf(), -) : ShellContentFocus { - var source by mutableStateOf(items) - var status by mutableStateOf(LibraryStatus.Ready) - var savedAt by mutableStateOf(null) - var sort by mutableStateOf(0) - var unwatched by mutableStateOf(false) - var mediaType by mutableStateOf("all") - var tail by mutableStateOf(GridTail.None) - var pagination: LibraryPagination? = null - var pageRevision by mutableStateOf(0) - var refreshRevision by mutableStateOf(0) - var pointerReentry: (() -> Int?)? = null - var index by mutableStateOf(0) - var toolbar by mutableStateOf(false) - var tool by mutableStateOf(0) - var inRail by mutableStateOf(false) - var railLetter by mutableStateOf('#') - private var railReturn = 0 - val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0 - val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } - val hasToolbar: Boolean get() = !catalogue || mixed - val items: List by derivedStateOf { - val filtered = source.filter { (!unwatched || !it.played) && (mediaType == "all" || it.type == mediaType) } - if (catalogue) filtered else when (sort) { - 1 -> filtered.sortedWith(compareByDescending { it.addedAt }.thenBy { it.sortName }) - 2 -> filtered.sortedWith(compareByDescending { it.leadScore }.thenBy { it.sortName }) - else -> filtered.sortedBy { it.sortName } - } - } - val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready - val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns - override val hasContent: Boolean get() = status != LibraryStatus.Loading - override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail" - val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index) - - override fun key(key: ShellKey): Boolean { - if (inRail) return railKey(key) - if (key == ShellKey.Back) return false - if (key == ShellKey.Up || key == ShellKey.Down || key == ShellKey.Left || key == ShellKey.Right) { - val reentry = pointerReentry - pointerReentry = null - reentry?.invoke()?.let { index = it; toolbar = false; return true } - } - if (status == LibraryStatus.Loading) return false - if (status == LibraryStatus.Unavailable || (items.isEmpty() && !toolbar)) { - when (key) { - ShellKey.Up -> if (status == LibraryStatus.Ready && hasToolbar) toolbar = true else return false - ShellKey.Ok -> if (status == LibraryStatus.Unavailable) onRecover() else if (unwatched) { unwatched = false; index = 0 } else onDiscover() - else -> Unit - } - return true - } - if (toolbar) return toolbarKey(key) - val currentItems = items - when (key) { - ShellKey.Up -> if (index >= columns) index -= columns else if (hasToolbar) toolbar = true else return false - ShellKey.Down -> when { - index / columns < currentItems.lastIndex / columns -> index = (index + columns).coerceAtMost(currentItems.lastIndex) - tail != GridTail.None && index < tailIndex -> index = tailIndex - } - ShellKey.Left -> if (index % columns > 0) index-- - ShellKey.Right -> if (index % columns < columns - 1 && index < currentItems.lastIndex) index++ else if (railVisible) { inRail = true; railReturn = index; railLetter = currentItems[index].letter } - ShellKey.Ok -> currentItems.getOrNull(index)?.let(onTitle) ?: if (pagination != null) pageRevision++ else onRecover() - } - onReadAhead(index) - return true - } - - private fun toolbarKey(key: ShellKey): Boolean { - val count = if (catalogue) 3 else 2 - when (key) { - ShellKey.Up -> return false - ShellKey.Down -> { toolbar = false; index = index.coerceIn(0, items.lastIndex.coerceAtLeast(0)) } - ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) - ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) - ShellKey.Ok -> { - if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] - else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched - index = 0 - } - else -> Unit - } - return true - } - - private fun railKey(key: ShellKey): Boolean { - val letters = items.map { it.letter }.distinct() - when (key) { - ShellKey.Up, ShellKey.Down -> { - val next = (letters.indexOf(railLetter) + if (key == ShellKey.Up) -1 else 1).coerceIn(0, letters.lastIndex) - railLetter = letters[next] - } - ShellKey.Left, ShellKey.Ok -> { index = items.indexOfFirst { it.letter == railLetter }; inRail = false } - ShellKey.Back -> { index = railReturn; inRail = false } - else -> Unit - } - return true - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt deleted file mode 100644 index dc25fbed..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPagination.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.justinvisser.wolfskin.design - -import kotlinx.coroutines.CancellationException - -/** Provider pages are merged by title identity; transport pagination is never displayed. */ -data class LibraryPage(val items: List, val hasMore: Boolean) - -/** Duplicate-only reads pause until Show more; failures pause until Try again. */ -class LibraryPagination(private val grid: LibraryGridState, private val load: suspend (Int) -> LibraryPage) { - private var nextPage = 2 - private var hasMore = true - private var reading = false - - suspend fun readAhead(force: Boolean = false) { - if (reading || !hasMore || !force && (grid.tail != GridTail.None || grid.index < grid.items.size - WolfskinTokens.Behaviour.nextPageCards)) return - reading = true - try { - val page = load(nextPage) - val known = grid.source.mapTo(mutableSetOf()) { it.catalogueKey } - val additional = page.items.filter { known.add(it.catalogueKey) } - nextPage++ - hasMore = page.hasMore - if (additional.isEmpty() && page.items.isNotEmpty() && hasMore) grid.tail = GridTail.ShowMore - else { - grid.source = grid.source + additional - grid.tail = GridTail.None - grid.index = grid.index.coerceAtMost(grid.items.lastIndex.coerceAtLeast(0)) - } - } catch (exception: Exception) { - if (exception is CancellationException) throw exception - grid.tail = GridTail.Retry - } finally { - reading = false - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt deleted file mode 100644 index 41e1e053..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ /dev/null @@ -1,62 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import coil3.compose.AsyncImage -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L - -/** Poster slots use exact requests and share the shell's draw-only focus treatment. */ -@Composable -fun LibraryPoster( - title: PosterContent, - focused: Boolean, - ratingsVisible: Boolean, - catalogue: Boolean, - modifier: Modifier = Modifier, - offlineArt: ((String) -> ImageBitmap?)? = null, -) { - val poster = title.poster - TitleCard(focused, modifier.testTag("poster-${title.id}")) { - val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } - if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - else if (poster != null && offlineArt == null) { - var ready by remember(poster) { mutableStateOf(false) } - if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie) - val context = LocalContext.current - val request = remember(poster, context) { WolfskinImages.request(context, poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) } - AsyncImage(request, title.posterTitle, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - onSuccess = { ready = true }, onError = { ready = false }) - } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) - if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) - val progress = title.progress - if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) - .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { - Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) - } - } - if (catalogue && title.badge != null) { - val held = title.badge == "held" - val color = if (title.badge == "failed") C.fail else C.accent - Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset) - .size(WolfskinTokens.Layout.badge).background(if (held) C.ok else C.badgeRest, CircleShape) - .then(if (held) Modifier else Modifier.border(L.badgeRing, color, CircleShape)), contentAlignment = Alignment.Center) { - Glyph(if (held) "check" else if (title.badge == "failed") "alert" else "clock", - if (held) C.onFocus else color, Modifier.size(L.badgeIcon)) - } - } - } -} - - diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt deleted file mode 100644 index fa339d43..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ /dev/null @@ -1,272 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.input.pointer.* -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.zIndex -import androidx.tv.material3.Text -import kotlinx.coroutines.delay -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Library as G -import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K - -/** One lazy frame owns headers, toolbar, continuous poster rows and recoverable tails. */ -@Composable -fun LibraryScreen( - shell: ShellState, - grid: LibraryGridState, - modifier: Modifier = Modifier, - ratingsSignedIn: Boolean = false, - cachedRatings: Set = emptySet(), - ratingsReachable: Boolean = true, - offlineArt: ((String) -> ImageBitmap?)? = null, - view: RememberedView = shell.view, - registerFocus: Boolean = true, -) { - val density = LocalDensity.current - val context = LocalContext.current - val active = !shell.navFocused && !shell.pickerVisible - val items = grid.items - val focused = grid.focusedTitle - val count = if (grid.catalogue) items.size else grid.source.size - var plateKey by remember { mutableStateOf(null) } - val ratingsReader = LocalScreenRatings.current - DisposableEffect(shell, grid, registerFocus) { - if (registerFocus) shell.contentFocus = grid - onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } - } - LaunchedEffect(grid) { - var revision = grid.pageRevision - snapshotFlow { Triple(grid.index, grid.source.size, grid.pageRevision) }.collect { (_, _, nextRevision) -> - grid.pagination?.readAhead(force = nextRevision != revision) - revision = nextRevision - } - } - LaunchedEffect(grid.index / grid.columns, items, offlineArt) { - if (offlineArt == null) { - val start = (grid.index / grid.columns + 1) * grid.columns - for (index in start until (start + grid.columns).coerceAtMost(items.size)) { - items[index].poster?.let { WolfskinImages.loader(context).enqueue(WolfskinImages.request(context, it, (K.posterW.value * 2).toInt(), (K.posterH.value * 2).toInt(), true)) } - } - } - } - LaunchedEffect(focused?.id, active, ratingsSignedIn, ratingsReachable) { - plateKey = null - if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) { - if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong()) - if (ratingsReader != null) { - val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull() - val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) } - if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it } - } - if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id) - plateKey = focused.id - } - } - LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } - LaunchedEffect(grid.status) { - if (grid.status == LibraryStatus.Loading && !shell.navFocused) shell.focusNav(shell.currentTab) - } - val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 - val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap - val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns - LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density, - grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { - if (grid.toolbar || focusedRow == 0 && active) { - animateLibraryScroll(view, headerCount, 0f, density) - } else if (focusedRow > 0) { - val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() } - animateLibraryScroll(view, headerCount, target, density) - } - } - Box(modifier.semantics { stateDescription = "${grid.title}:${if (grid.toolbar) "tool-${grid.tool}" else if (grid.inRail) "rail-${grid.railLetter}" else focused?.id ?: "recovery"}" }.testTag("library-grid")) { - LazyColumn(state = view.listState, modifier = Modifier.fillMaxSize().pointerInput(grid, headerCount) { - awaitPointerEventScope { - while (true) { - if (awaitPointerEvent().type == PointerEventType.Scroll) grid.pointerReentry = { - val visible = view.listState.layoutInfo.visibleItemsInfo - if (visible.none { it.index == headerCount + grid.index / grid.columns }) { - visible.firstOrNull { it.index >= headerCount }?.let { ((it.index - headerCount) * grid.columns).coerceAtMost(grid.items.lastIndex) } - } else null - } - } - } - }, contentPadding = PaddingValues(bottom = WolfskinTokens.Shell.stageHeight - L.pinnedLine - rowHeight)) { - item(key = "header", contentType = "header") { - LibraryHeader(shell.person, grid.title, - if (grid.status != LibraryStatus.Ready) "" else "$count ${if (grid.catalogue) "titles" else if (grid.title == "Movies") if (count == 1) "film" else "films" else "series"}", - grid.savedAt) - } - if (headerCount == 2) item(key = "toolbar", contentType = "chips") { LibraryToolbar(grid, active) } - when (grid.status) { - LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { - Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) } - } - } - } - LibraryStatus.Unavailable -> item(key = "unavailable", contentType = "state") { LibraryRecovery(grid, active) } - LibraryStatus.Ready -> if (items.isEmpty()) item(key = "empty", contentType = "state") { LibraryRecovery(grid, active && !grid.toolbar) } - else { - items((items.size + grid.columns - 1) / grid.columns, key = { row -> - val title = items[row * grid.columns] - "grid-${if (grid.catalogue) title.catalogueKey else title.id}" - }, contentType = { "grid" }) { row -> - Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - for (column in 0 until grid.columns) { - val index = row * grid.columns + column - val title = items.getOrNull(index) ?: break - key(if (grid.catalogue) title.catalogueKey else title.id) { - val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index - LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, - Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - if ((event.type == PointerEventType.Move || event.type == PointerEventType.Enter) && event.changes.any { it.type == PointerType.Mouse }) { - grid.index = index; grid.toolbar = false; shell.navFocused = false - } - } - } - }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) - } - } - } - if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) { - val title = items.getOrNull(grid.index) - if (title != null) Caption(title.title, title.facts(grid.catalogue), true, - Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth), - accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f) - } - } - } - if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") { - LibraryTail(grid, active && grid.index == grid.tailIndex) - } - } - } - } - if (grid.railVisible) LibraryRail(grid) - } -} - -/** Library, catalogue and service headers share one baseline and person treatment. */ -@Composable -fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", savedAt: String? = null) { - Box(Modifier.fillMaxWidth().height(L.row_lhead)) { - Row(Modifier.offset(L.safeSide, L.navBand), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.settingsHeaderGap), verticalAlignment = Alignment.CenterVertically) { - Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text(title, modifier = Modifier.alignByBaseline().exactLineBox(WolfskinTokens.Type.screenTitle), color = C.fg, style = WolfskinTokens.Type.screenTitle) - if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) - savedAt?.let { SavedPill(it) } - } - } -} - -@Composable -private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { - Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { - val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") - labels.forEachIndexed { index, label -> - val focused = active && grid.toolbar && grid.tool == index - val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched - Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) - .then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) - .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } - .padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { - val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg - if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon)) - if (!grid.catalogue && index == 0) Text(buildAnnotatedString { - withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } - append(" ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}") - }, color = color, style = WolfskinTokens.Type.label) - else Text(label, color = color, style = WolfskinTokens.Type.label) - } - } - } -} - -@Composable -private fun LibraryRecovery(grid: LibraryGridState, focused: Boolean) { - val lineBoxes = androidx.compose.ui.text.style.LineHeightStyle( - androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center, - androidx.compose.ui.text.style.LineHeightStyle.Trim.None, - ) - val unavailable = grid.status == LibraryStatus.Unavailable - val title = if (unavailable) "Couldn’t reach your library" else if (grid.unwatched) "Nothing unwatched in ${grid.title}" else "Nothing in ${grid.title} yet" - val text = if (unavailable) "Jellyfin isn’t answering. Nothing here is lost; try again in a moment." else if (grid.unwatched) "Everything here has been watched." else "Titles you or others request for you land here." - val button = if (unavailable) "Try again" else if (grid.unwatched) "Show all" else "Open Discover" - Box(Modifier.fillMaxWidth().height(L.row_state).padding(start = L.safeSide, top = G.panelTop)) { - Column(Modifier.width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .border(G.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(G.borderWidth) - .padding(horizontal = G.panelPaddingX, vertical = G.panelPaddingY)) { - Row(horizontalArrangement = Arrangement.spacedBy(G.panelIconGap), verticalAlignment = Alignment.CenterVertically) { - Glyph(if (unavailable) "alert" else if (grid.title == "Movies") "film" else "nextup", if (unavailable) C.warn else C.accentText, Modifier.size(WolfskinTokens.Shell.panelIcon)) - Text(title, color = C.fg, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBoxes)) - } - Spacer(Modifier.height(G.panelTitleGap)) - Text(text, color = C.muted, style = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBoxes)) - Spacer(Modifier.height(G.panelTextGap)) - PickerButton(button, focused, !unavailable) { grid.key(ShellKey.Ok) } - } - } -} - -@Composable -private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { - val retry = grid.tail == GridTail.Retry - Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { - CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) - Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, - Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) - } -} - -@Composable -private fun BoxScope.LibraryRail(grid: LibraryGridState) { - Column(Modifier.align(Alignment.TopEnd).padding(end = L.safeSide, top = L.navBand).width(G.railWidth)) { - val letters = remember(grid.items) { grid.items.map { it.letter }.toSet() } - for (letter in "#ABCDEFGHIJKLMNOPQRSTUVWXYZ") { - val current = if (grid.inRail) grid.railLetter == letter else grid.focusedTitle?.letter == letter - Box(Modifier.width(G.railWidth).height(if (current && grid.inRail) G.railFocusHeight else G.railLetterHeight) - .background(if (current && grid.inRail) C.fg else androidx.compose.ui.graphics.Color.Transparent, CircleShape) - .clickable(enabled = letter in letters) { grid.railLetter = letter; grid.inRail = true; grid.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Text(letter.toString(), color = if (current) if (grid.inRail) C.onFocus else C.accent else if (letter in letters) C.muted else C.railEmpty, - style = if (current && grid.inRail) G.railFocusedLetter else G.railLetter) - } - } - } -} - -private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, target: Float, density: androidx.compose.ui.unit.Density) { - val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap - val first = view.listState.firstVisibleItemIndex - val current = with(density) { - (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + - if (first < headerCount) WolfskinTokens.Shell.zero else - (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() - } + view.listState.firstVisibleItemScrollOffset - animateFrameScroll(view.listState, target - current) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt deleted file mode 100644 index 0dabee9b..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibrarySeeAllCard.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.tv.material3.Text - -/** Shelves expose the full underlying list without displaying transport pagination. */ -@Composable -fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, - width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH, - titleStyle: TextStyle = WolfskinTokens.Type.listTitle, countStyle: TextStyle = WolfskinTokens.Type.label) { - val shape = RoundedCornerShape(WolfskinTokens.Radius.card) - TitleCard(focused, modifier.clickable(onClick = onClick), width, height) { - Column(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface, shape) - .border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape), - verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { - Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge)) - Text("See all", modifier = Modifier.exactLineBox(titleStyle), color = WolfskinTokens.Color.fg, style = titleStyle) - Text(count.toString(), modifier = Modifier.exactLineBox(countStyle), color = WolfskinTokens.Color.muted, style = countStyle) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt deleted file mode 100644 index f88d4458..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.spring -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S -import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V - -/** Blob bounds retain their measurement while their draw coordinates spring between targets. */ -data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp) - -fun navBoxes(tabs: List): List { - var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap - return tabs.map { tab -> - val width = when (tab) { - "home" -> S.navHomeWidth - "discover" -> S.navDiscoverWidth - "movies" -> S.navMoviesWidth - "shows" -> S.navShowsWidth - else -> S.navAnimeWidth - } - NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap } - } + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar) -} - -/** There is one blob, including when focus wraps around the nav avatar. */ -@Composable -fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { - val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } - val labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } - val index = if (state.navFocused) state.navIndex else state.currentTab - val box = boxes[index] - val avatarFocused = state.navFocused && index == state.tabs.size - val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero - val damping = WolfskinTokens.Motion.navBlobDampingRatio - val stiffness = WolfskinTokens.Motion.navBlobStiffness - val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x") - val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y") - val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width") - val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") - val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") - val density = LocalDensity.current.density - val band = remember { - Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) - } - Box(modifier.fillMaxWidth().height(L.navBand)) { - Canvas(Modifier.matchParentSize()) { - if (scrolled) drawRect(C.bg) else drawRect(band) - drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) - } - Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) - state.tabs.forEachIndexed { itemIndex, tab -> - val item = boxes[itemIndex] - val focused = state.navFocused && state.navIndex == itemIndex - val labelColor by animateColorAsState( - if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, - animationSpec = labelFade, - label = "nav-label-$tab", - ) - Box(Modifier.offset(item.left, item.top).size(item.width, item.height) - .focusProperties { canFocus = false } - .clickable { - state.focusNav(itemIndex); state.key(ShellKey.Ok) - }, contentAlignment = Alignment.Center) { - Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) - if (itemIndex == state.currentTab && !focused) { - Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) - } - } - } - state.navSavedAt?.let { time -> - Row(Modifier.align(Alignment.TopEnd) - .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) - .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) - .padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { - Glyph("wifi", C.warn, Modifier.size(V.icon)) - Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) - } - } - val avatar = boxes.last() - Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, - Modifier.offset(avatar.left, avatar.top) - .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) - .focusProperties { canFocus = false } - .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt deleted file mode 100644 index 011a1d70..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -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.foundation.gestures.animateScrollBy -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Filmography retains a real credit key and requests more credits before focus reaches the tail. */ -class PersonState(initialPerson: TitlePerson, initialCredits: List, private val onAction: (TitleAction) -> Unit, private val onReadMore: () -> Unit = {}) { - var person by mutableStateOf(initialPerson) - private set - var credits by mutableStateOf(initialCredits) - var savedAt by mutableStateOf(null) - var toast by mutableStateOf(null) - fun update(actor: TitlePerson, titles: List, saved: String? = null) { - person = actor; credits = titles; savedAt = saved - index = index.coerceAtMost((credits.size - 1).coerceAtLeast(0)) - } - var index by mutableStateOf(0) - private var armed: String? = null - - /** Pointer activation and remote key-up open the same selected credit. */ - fun click(position: Int) { index = position; onAction(TitleAction.OpenTitle(credits[index])) } - - /** The first grid row exits upward into the existing shell nav. */ - fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { - if (credits.isEmpty()) { - if (down && key == ShellKey.Back) onAction(TitleAction.Pop) - else if (down && key == ShellKey.Up) shell.focusNav(shell.currentTab) - return true - } - val columns = WolfskinTokens.Behaviour.gridColumns.toInt() - if (key == ShellKey.Ok) { - if (repeat > 0) return true - if (down) armed = credits[index].id else { if (armed == credits[index].id) click(index); armed = null } - return true - } - if (!down) return true - armed = null - when (key) { - ShellKey.Back -> onAction(TitleAction.Pop) - ShellKey.Up -> if (index < columns) shell.focusNav(shell.currentTab) else index -= columns - ShellKey.Down -> if (index / columns < credits.lastIndex / columns) index = (index + columns).coerceAtMost(credits.lastIndex) - ShellKey.Left -> if (index % columns > 0) index-- - ShellKey.Right -> if (index % columns < columns - 1 && index < credits.lastIndex) index++ - else -> Unit - } - if (index >= credits.size - WolfskinTokens.Behaviour.nextPageCards.toInt()) onReadMore() - return true - } -} - -/** Portrait, biography and the full continuous grid use the shell's remembered frame. */ -@Composable -fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { - val view = shell.view - LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt } - val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } - LaunchedEffect(state) { shell.navFocused = false } - Box(modifier.fillMaxSize().background(C.bg)) { - androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { - ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) } - } - if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } - } -} - -@Composable -private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { - val density = LocalDensity.current - val list = shell.view.listState - var wheelPending by remember { mutableStateOf(false) } - val columns = WolfskinTokens.Behaviour.gridColumns.toInt() - LaunchedEffect(state.index / columns, shell.navFocused) { - val target = if (shell.navFocused || state.index < columns) 0f - else with(density) { (L.row_phead + L.row_gh + D.gridHeight * (state.index / columns) - L.pinnedLine).toPx() } - val first = list.firstVisibleItemIndex - val before = when (first) { - 0 -> WolfskinTokens.Shell.zero - 1 -> L.row_phead - else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) - } - val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset - if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) - } - Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } - .titleWheel { wheelPending = true; shell.navFocused = false } - .onPreviewKeyEvent { event -> - val key = readShellKey(event) - if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { - val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index >= 2 && it.key != "end-padding" && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } } - state.index = ((visible?.index?.minus(2) ?: 0) * columns).coerceAtMost((state.credits.size - 1).coerceAtLeast(0)) - wheelPending = false - if (key != ShellKey.Ok) return@onPreviewKeyEvent true - } - key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false - }.focusable()) { - LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { - item(key = "person-header", contentType = "header") { - Box(Modifier.height(L.row_phead)) { - Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { - PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true) - Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) { - Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom)) - Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body) - if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3) - } - } - } - } - item(key = "filmography", contentType = "header") { - Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) - } - itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> - Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { - row.forEachIndexed { column, item -> - val index = rowIndex * columns + column - TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } - } - } - } - item(key = "end-padding", contentType = "spacer") { Spacer(Modifier.height(WolfskinTokens.Shell.stageHeight - L.pinnedLine - D.gridHeight)) } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt deleted file mode 100644 index 3e8dd23c..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.DpOffset -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Recovery messages remain transient and never take focus from the action that failed. */ -@Composable -fun RecoveryToast(message: String, modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize()) { - val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } - Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop) - .widthIn(max = H.toastWidth) - .dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity), - offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY))) - .background(C.surface2, shape) - .testTag("recovery-toast") - .padding(horizontal = H.toastPadding, vertical = H.toastPaddingY), - horizontalArrangement = Arrangement.spacedBy(H.toastGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("alert", C.warn, Modifier.size(L.buttonIcon)) - Text(message, Modifier.weight(1f, fill = false).exactLineBox(HomeType.body), color = C.fg, style = HomeType.body) - } - } -} - -/** Shared wording is independent of transport details and never exposes exception text. */ -object RecoveryMessage { - const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." - const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again." -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt deleted file mode 100644 index 4ab9cc13..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -/** Overlay focus is independent of the screen below and never reaches the navigation bar. */ -sealed interface DiscoverOverlayState { - class Request(val state: RequestSheetState) : DiscoverOverlayState - class Hidden(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) } - class Restore(val title: DiscoverTitle) : DiscoverOverlayState { var focus by mutableStateOf(0) } -} - -/** Request selection cannot supply an actor and cannot submit an unknown-season series. */ -class RequestSheetState( - val title: DiscoverTitle, - val viewer: ShellPerson, - people: List, - other: Boolean, - initialPhase: RequestPhase = RequestPhase.Choose, -) { - var people by mutableStateOf(listOf(viewer) + people.filter { it.key != viewer.key }) - private set - val held = viewer.key in title.heldBy - val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty() - val buttonIndex: Int get() = people.size - var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0) - var phase by mutableStateOf(initialPhase) - var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected) - var result by mutableStateOf(null) - val selectedPerson: ShellPerson get() = people[selected] - val canSubmit: Boolean get() = phase == RequestPhase.Choose && !blocked && selectedPerson.key !in title.heldBy - val buttonLabel: String get() = when { - held -> "Add to ${selectedPerson.name}’s library" - selectedPerson.key == viewer.key -> "Request" - else -> "Request for ${selectedPerson.name}" - } - - fun updatePeople(value: List) { - val selectedKey = selectedPerson.key - val focusedKey = people.getOrNull(focus)?.key - val footerOffset = focus - buttonIndex - people = listOf(viewer) + value.filter { it.key != viewer.key } - selected = people.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0) - focus = if (footerOffset >= 0) buttonIndex + footerOffset else people.indexOfFirst { it.key == focusedKey }.coerceAtLeast(0) - } - - fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) { - if (key == ShellKey.Back) { close(); return } - when (phase) { - RequestPhase.Choose -> { - val order = people.indices.filter { people[it].key !in title.heldBy } + buttonIndex - val index = order.indexOf(focus) - when (key) { - ShellKey.Up -> focus = order[(index - 1).coerceAtLeast(0)] - ShellKey.Down -> focus = order[(index + 1).coerceAtMost(order.lastIndex)] - ShellKey.Ok -> if (focus == buttonIndex) { if (blocked) close() else if (canSubmit) send(this, false) } - else { selected = focus; focus = buttonIndex } - else -> Unit - } - } - RequestPhase.Sending -> Unit - RequestPhase.LoadingRecipients -> if (key == ShellKey.Ok) close() - RequestPhase.Receipt, RequestPhase.Refused -> if (key == ShellKey.Ok) close() - RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable -> { - when (key) { - ShellKey.Left, ShellKey.Up -> focus = buttonIndex - ShellKey.Right, ShellKey.Down -> focus = buttonIndex + 1 - ShellKey.Ok -> if (focus != buttonIndex) close() else if (phase == RequestPhase.RecipientsUnavailable) retryRecipients() else send(this, true) - else -> Unit - } - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt deleted file mode 100644 index 48cea4fa..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H -import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S - -/** Headers identify a retained copy without becoming a separate focus target. */ -@Composable -fun SavedPill(time: String, modifier: Modifier = Modifier) { - Box(modifier.height(S.settingsPillHeight) - .border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill)) - .padding(horizontal = S.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) { - Text("Saved $time", color = C.warn, style = HomeType.caption.copy(fontWeight = HomeType.label.fontWeight)) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt deleted file mode 100644 index 275ac228..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.compositionLocalOf - -/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ -interface ScreenRatings { - suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean = false): List? -} - -val LocalScreenRatings = compositionLocalOf { null } - -/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ -fun LibraryRating.homeRating(): HomeRating = HomeRating( - mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } }, - score = score, percentage = source.startsWith("rt"), episode = episode, -) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt deleted file mode 100644 index 0bc4d335..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ /dev/null @@ -1,128 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S - -/** Settings routes identify real upstream destinations rather than stand-in panels. */ -data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) - -val SETTINGS_GROUPS = listOf( - SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"), - SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"), - SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"), - SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"), - SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true), - SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true), - SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true), - SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), -) - -/** The household request count shares the sheet's snapshot and excludes Ready. */ -@Composable -fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) { - val list = state.view.listState - val density = LocalDensity.current - LaunchedEffect(state.settingsRow, state.navFocused) { - if (state.navFocused) return@LaunchedEffect - val y = L.row_lhead + L.row_srow * state.settingsRow - val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero - val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) - val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) - } - LazyColumn(modifier.fillMaxSize(), state = list, - contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { - item(key = "settings-header") { - Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { - Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) - Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) - } - } - itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> - Box(Modifier.height(L.row_srow)) { - SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, - if (group.key == "requests") openRequests else null, - Modifier.offset(L.safeSide, S.settingsRowTop)) { - state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) - } - } - } - } -} - -/** The inverse focus fill has no focus ring and never changes row layout. */ -@Composable -fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) { - val foreground = if (focused) C.onFocus else C.fg - Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow) - .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { - Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) - Column(Modifier.weight(1f)) { - val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" - Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (group.wolfskin) SettingsPill("Wolfskin", focused) - SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) - Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) - } -} - -@Composable -private fun SettingsPill(label: String, focused: Boolean) { - val foreground = if (focused) C.onFocus else C.dim - val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder - Box(Modifier.height(S.settingsPillHeight).drawWithCache { - val stroke = S.settingsPillStroke.toPx() - val inset = Offset(stroke / 2, stroke / 2) - val bounds = Size(size.width - stroke, size.height - stroke) - val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect( - floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio), - )) - val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx()) - onDrawBehind { - drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) - } - }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { - Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt deleted file mode 100644 index c770a3ab..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ /dev/null @@ -1,324 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -/** Household identity data contains no credentials or server objects. */ -data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean, val jellyfinUserId: String? = null) - -/** The shell owns keys; the integration owns navigation and authenticated user changes. */ -sealed interface ShellAction { - data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction - data class Person(val person: ShellPerson) : ShellAction - data class HandOff(val group: String) : ShellAction - data object Pop : ShellAction - data object RetryPeople : ShellAction - data object RetryRequests : ShellAction - data class RequestTitle(val request: ComingInRequest) : ShellAction -} - -/** D-pad inputs are shared by Android events and deterministic JVM replays. */ -enum class ShellKey { Up, Down, Left, Right, Ok, Back } -enum class PeopleStatus { Ready, Loading, Unavailable } - -/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */ -interface ShellContentFocus { - val hasContent: Boolean get() = true - val cardKey: String? - fun key(key: ShellKey): Boolean -} - -/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */ -class RememberedView { - val listState = LazyListState() - var row by mutableStateOf(0) - val cards = mutableStateMapOf() - var savedAt by mutableStateOf(null) - val home by lazy { HomeViewMemory() } - var library: LibraryGridState? = null -} - -/** Focus and Back transitions are independent of drawing and server availability. */ -class ShellState( - people: List, - initialPerson: String, - private val onAction: (ShellAction) -> Unit = {}, -) { - var people by mutableStateOf(people) - private set - - /** Refresh household data without losing valid nav focus or this person's view memory. */ - fun updatePeople(value: List) { - val focusedTab = tabs.getOrNull(navIndex) - val avatarFocused = navIndex == tabs.size - val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } - ?: value.firstOrNull { it.key == person.key } - people = value - if (selected != null) person = selected - pickerIndex = people.indexOf(person).coerceAtLeast(0) - val removedRoot = rootRoute !in tabs - if (removedRoot) { - if (route == rootRoute) route = "home" - rootRoute = "home" - } - preparePersonMemory() - navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab - if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true)) - } - var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) - private set - var route by mutableStateOf("home") - private set - var pickerVisible by mutableStateOf(true) - var pickerLaunch by mutableStateOf(true) - var pickerIndex by mutableStateOf(people.indexOf(person)) - var gearFocused by mutableStateOf(false) - var navFocused by mutableStateOf(false) - var navIndex by mutableStateOf(0) - var peopleStatus by mutableStateOf(PeopleStatus.Ready) - var recoveryIndex by mutableStateOf(0) - var requestsVisible by mutableStateOf(false) - var toast by mutableStateOf(null) - private val personRequests = mutableMapOf() - val requests: ComingInState get() = personRequests.getValue(person.key) - private var requestReturnRoute: String? = null - var handOff by mutableStateOf(null) - var settingsRow by mutableStateOf(0) - var contentOverlayVisible by mutableStateOf(false) - private var rootRoute by mutableStateOf("home") - private var pickerReturnRoute = "home" - private val views = mutableStateMapOf, RememberedView>() - private var armedCard: String? = null - var contentFocus: ShellContentFocus? = null - var inputRevision = 0L - private set - - init { preparePersonMemory() } - - private fun preparePersonMemory() { - personRequests.getOrPut(person.key) { ComingInState() } - tabs.forEach(::retainView) - retainView(route) - } - - private fun retainView(destination: String) { - views.getOrPut(person.key to destination) { RememberedView() } - } - val tabs: List get() = if (person.hasAnime) ANIME_TABS else BASE_TABS - val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) - val view: RememberedView get() = rememberedView(route) - val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt - - /** Reads never register defaults: snapshotFlow evaluates accessors in a read-only snapshot. */ - fun rememberedView(destination: String): RememberedView = - views[person.key to destination] ?: RememberedView() - - /** External intents and upstream pops remain authoritative over the screen. */ - fun enterDestination(destination: String) { - retainView(destination) - route = destination - if (destination in tabs) rootRoute = destination - handOff = null - if (requestReturnRoute == destination) { - requestsVisible = true - requestReturnRoute = null - } - } - - /** Pointer navigation uses the same transitions as D-pad selection. */ - fun focusNav(index: Int) { inputRevision++; navIndex = index; navFocused = true } - fun focusPerson(index: Int) { inputRevision++; pickerIndex = index; gearFocused = false } - - /** Repeats are consumed, and a row activation is cancelled when focus moves. */ - fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = if (!pickerVisible && !navFocused && !requestsVisible && !contentOverlayVisible) contentFocus?.cardKey else null): Boolean { - if (down) inputRevision++ - if (key == ShellKey.Ok && repeat > 0) return true - if (!pickerVisible && !requestsVisible && contentOverlayVisible) { - if (down) contentFocus?.key(key) - return true - } - if (key == ShellKey.Ok) { - if (card != null) { - if (down) armedCard = card else { - if (armedCard == card) activate() - armedCard = null - } - return true - } - if (!down) return true - } else { - if (!down) return true - armedCard = null - } - if (requestsVisible) { - when { - key == ShellKey.Back -> closeRequests() - key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests() - key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() - requests.key(key) -> activateRequest() - } - return true - } - if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key) - return true - } - - private fun pickerKey(key: ShellKey) { - if (peopleStatus != PeopleStatus.Ready) { - when (key) { - ShellKey.Left -> recoveryIndex = 0 - ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1 - ShellKey.Back -> closePicker() - ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person) - else -> Unit - } - } else if (gearFocused) { - when (key) { - ShellKey.Up -> gearFocused = false - ShellKey.Ok -> { - pickerVisible = false - navFocused = false - settingsRow = 0 - retainView("settings") - route = "settings" - onAction(ShellAction.Destination(route)) - } - ShellKey.Back -> closePicker() - else -> Unit - } - } else { - when (key) { - ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0) - ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex) - ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true - ShellKey.Ok -> choosePerson(people[pickerIndex]) - ShellKey.Back -> closePicker() - else -> Unit - } - } - } - - private fun choosePerson(selected: ShellPerson) { - person = selected - pickerVisible = false - pickerLaunch = false - route = "home" - navFocused = false - navIndex = 0 - rootRoute = "home" - requestReturnRoute = null - requestsVisible = false - preparePersonMemory() - views[person.key to route] = RememberedView() - onAction(ShellAction.Person(selected)) - } - - private fun closePicker() { - if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size } - } - - private fun navKey(key: ShellKey) { - when (key) { - ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1) - ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1) - ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false - ShellKey.Ok -> activate() - else -> Unit - } - } - - private fun contentKey(key: ShellKey) { - if (contentFocus?.key(key) == true) return - when (key) { - ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab } - ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex) - ShellKey.Ok -> activate() - else -> Unit - } - } - - private fun activate() { - if (navFocused) { - if (navIndex == tabs.size) { - pickerReturnRoute = route - pickerVisible = true - pickerLaunch = false - pickerIndex = people.indexOf(person) - gearFocused = false - } else { - val target = tabs[navIndex] - requestReturnRoute = null - retainView(target) - if (target == route) views[person.key to target] = RememberedView() - route = target - rootRoute = target - navFocused = false - onAction(ShellAction.Destination(target)) - } - } else if (route == "settings") { - val group = SETTINGS_GROUPS[settingsRow].key - if (group == "requests") openRequests() else { - handOff = group - onAction(ShellAction.HandOff(group)) - } - } - else contentFocus?.key(ShellKey.Ok) - } - - /** Requested buttons may name an existing request without changing its authenticated actor. */ - fun openRequests(requestId: String? = null) { - requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id) - requestsVisible = true - navFocused = false - } - - fun closeRequests() { requestsVisible = false } - fun retryRequests() { onAction(ShellAction.RetryRequests) } - - fun activateRequest() { - val request = requests.focused ?: return - requestReturnRoute = route - requestsVisible = false - onAction(ShellAction.RequestTitle(request)) - } - - /** Back never discards a root view, including the card below a focused nav. */ - fun back() { - if (requestsVisible) { closeRequests(); return } - val returnRoute = requestReturnRoute - if (returnRoute != null && route != returnRoute) { - route = returnRoute - requestsVisible = true - requestReturnRoute = null - onAction(ShellAction.Pop) - return - } - if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return - if (route == "settings") { - route = pickerReturnRoute - onAction(ShellAction.Pop) - pickerVisible = true - pickerLaunch = false - gearFocused = true - pickerIndex = people.lastIndex - } else if (route !in tabs) { - onAction(ShellAction.Pop) - } else if (!navFocused) { - navFocused = true - navIndex = currentTab - } else if (route != "home") { - route = "home" - rootRoute = "home" - navIndex = 0 - onAction(ShellAction.Destination(route)) - } - } - - companion object { - private val BASE_TABS = listOf("home", "discover", "movies", "shows") - private val ANIME_TABS = BASE_TABS + "anime" - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt deleted file mode 100644 index 5eca15da..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.focusGroup -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -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.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L - -/** Screens and the shell interpret the same Android directional and activation keys. */ -fun readShellKey(event: KeyEvent): ShellKey? = when (event.key) { - Key.DirectionUp -> ShellKey.Up - Key.DirectionDown -> ShellKey.Down - Key.DirectionLeft -> ShellKey.Left - Key.DirectionRight -> ShellKey.Right - Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok - Key.Back, Key.Escape -> ShellKey.Back - else -> null -} - -/** Map Android key events once, preserving repeat count and key-up semantics. */ -fun ShellState.handleKey(event: KeyEvent): Boolean { - val key = readShellKey(event) ?: return false - return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount) -} - -/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */ -@Composable -fun ShellSurface( - state: ShellState, - modifier: Modifier = Modifier, - handlesContentKeys: Boolean = true, - settingsOpenRequests: Int? = null, - overlay: @Composable () -> Unit = {}, - content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, -) { - val focus = remember { FocusRequester() } - val contentFocus = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - LaunchedEffect(state.toast) { - val message = state.toast - if (message != null) { - kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()) - if (state.toast == message) state.toast = null - } - } - LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { - if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() - else contentFocus.requestFocus() - } - Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell") - .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } - .focusRequester(focus) - .onPreviewKeyEvent { - if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false - } - .onKeyEvent { - if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { - if (!focusManager.moveFocus(FocusDirection.Up)) { - state.navFocused = true - state.navIndex = state.currentTab - } - true - } else false - }.focusable()) { - if (state.pickerVisible) { - WhosWatching(state) - } else { - if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) - NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) - if (state.requestsVisible) ComingInSheet(state) - overlay() - } - state.toast?.let { RecoveryToast(it) } - } -} - -/** Future screens are explicitly out of scope, never simulated library or title data. */ -@Composable -fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) { - Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { - Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle) - Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body) - Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt deleted file mode 100644 index 018e4a4b..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.getValue -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.tv.material3.Text -import java.util.Locale -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D -import org.justinvisser.wolfskin.design.WolfskinTokens.Library as P - -/** A full facts-line set never shrinks or waits for focused-card dwell. */ -@Composable -fun TitleRatings(ratings: List, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.ratingPlate, plate: Boolean = false) { - if (ratings.isEmpty()) return - val density = LocalDensity.current - val markHeight = with(density) { style.fontSize.toDp() } - val lineHeight = style.fontSize * (WolfskinTokens.Type.ratingPlate.lineHeight.value / WolfskinTokens.Type.ratingPlate.fontSize.value) - val textStyle = if (style.lineHeight == lineHeight) style else style.copy(lineHeight = lineHeight) - val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) } - val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap - val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap - Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { - ratings.forEach { rating -> - Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { - val resource = when (rating.source) { - "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh - "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright - "imdb" -> R.drawable.ic_rating_imdb - else -> R.drawable.ic_rating_tmdb - } - val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten" - val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null - Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight), - alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated) - if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.exactLineBox(textStyle)) - Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.exactLineBox(numberStyle), maxLines = 1) - } - } - } -} - -/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */ -@Composable -fun LibraryRatingPlate(ratings: List, modifier: Modifier = Modifier, visible: Boolean = true) { - if (ratings.isEmpty()) return - val measurer = rememberTextMeasurer() - val density = LocalDensity.current - val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") - BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha } - .background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f))) - .testTag("rating-plate").padding(horizontal = P.plateSide)) { - val width = with(density) { maxWidth.toPx() } - val fit = remember(ratings, width, density, Locale.getDefault()) { - var size = WolfskinTokens.Type.ratingPlate.fontSize.value - var count = ratings.size - val numbers = ratings.map { it.formatted() } - fun measured(): Float { - val fontSize = TextUnit(size, TextUnitType.Sp) - val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, fontFeatureSettings = "tnum") - val markGap = with(density) { (fontSize * P.markGapEm).toPx() } - val entryGap = with(density) { (fontSize * P.plateGapEm).toPx() } - var total = 0f - for (index in 0 until count) { - val rating = ratings[index] - total += measurer.measure(numbers[index], style, maxLines = 1).size.width - total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f - total += markGap - if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap - if (index > 0) total += entryGap - } - return total - } - while (size > P.plateFloor.fontSize.value && measured() > width) size = (size - P.plateStep).coerceAtLeast(P.plateFloor.fontSize.value) - while (count > 1 && measured() > width) count-- - count to size - } - val fontSize = TextUnit(fit.second, TextUnitType.Sp) - val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, - lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)) - TitleRatings(ratings.take(fit.first), modifier = Modifier.exactLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt deleted file mode 100644 index b21c4dcb..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit - -/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */ -fun Modifier.exactLineBox(style: TextStyle, top: Dp = WolfskinTokens.Shell.zero, bottom: Dp = WolfskinTokens.Shell.zero): Modifier = - if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt deleted file mode 100644 index 1ea37eb4..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ /dev/null @@ -1,176 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.ShaderBrush -import androidx.compose.ui.graphics.toArgb -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Local decoded fixture art keeps screenshot tests deterministic and completely offline. */ -val LocalTitleBitmaps = compositionLocalOf> { emptyMap() } - -/** Decode at the slot size, with no crossfade or oversized poster allocations. */ -@Composable -fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) { - val bitmap = LocalTitleBitmaps.current[path] - if (bitmap != null) Image(bitmap, null, modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) - else if (path != null) { - val context = LocalContext.current - val request = remember(context, path, width, height, poster) { - WolfskinImages.request(context, path, width, height, poster) - } - AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), - contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter, onError = { onError() }) - } -} - -/** Initials follow the same title-hashed hue as the locked art fallback. */ -@Composable -fun PersonPortrait(person: TitlePerson, modifier: Modifier = Modifier, large: Boolean = false) { - val initials = remember(person.name) { person.name.split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { it.take(1) }.uppercase() } - val size = if (large) (L.personPortrait.value * 2).toInt() else WolfskinTokens.Image.castRequestWidth - Box(modifier.titleFallbackBackground(person.name, D.personFallbackGradient), contentAlignment = Alignment.Center) { - Text(initials, color = D.initialsColor, style = if (large) D.personInitialSize else D.initialSize, textAlign = TextAlign.Center) - if (person.portrait != null) TitleArtwork(person.portrait, size, size, Modifier.fillMaxSize()) - } -} - -/** The original gradient is parsed once so stops stay derived from the canonical token. */ -@Composable -fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { - val horizontal = remember { - val stops = Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(WolfskinTokens.Gradient.detailScrim).take(3) - .map { it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat()) }.toList() - Brush.horizontalGradient(*stops.toTypedArray()) - } - val vertical = remember { - val token = WolfskinTokens.Gradient.detailScrim.substringAfter("linear-gradient(0deg,") - val stops = mutableListOf(1f to C.bg) - Regex("rgb\\(7 7 10 / ([.\\d]+)\\) (\\d+)%").findAll(token).forEach { - stops.add(1f - it.groupValues[2].toFloat() / 100 to C.bg.copy(alpha = it.groupValues[1].toFloat())) - } - Brush.verticalGradient(*stops.sortedBy { it.first }.toTypedArray()) - } - Box(modifier.titleAmbientBackground()) { - if (item.backdrop != null && !item.isPosterBackdrop) { - TitleArtwork(item.backdrop, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize()) - } else if (item.poster != null) { - val filter = remember { - val token = WolfskinTokens.Gradient.heroAmbientFilter - Regex("blur\\(([\\d.]+)px\\).*saturate\\(([\\d.]+)\\).*brightness\\(([\\d.]+)\\)").find(token)!!.groupValues - } - val colorFilter = remember { - val matrix = ColorMatrix().apply { setToSaturation(filter[2].toFloat()) } - for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() - ColorFilter.colorMatrix(matrix) - } - TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, - Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale } - .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) - val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) } - val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } - TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), - Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) - .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape)) - } - Box(Modifier.fillMaxSize().background(horizontal)) - Box(Modifier.fillMaxSize().background(vertical)) - } -} - -/** Library, related and person posters share the exact locked missing-art treatment. */ -@Composable -internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) { - Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) { - Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph)) - if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset)) - } -} - -/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */ -@Composable -fun TitleTileArtwork(item: TitleItem) { - Box(Modifier.fillMaxSize()) { - MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false) - if (item.isPosterBackdrop && item.poster != null) { - val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { - for (index in 0 until 15) values[index] *= D.tileAmbientBrightness - }) } - TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) - val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 - val shape = remember { RoundedCornerShape(D.tileSheetRadius) } - val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } - TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), - Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize()) - } -} - -private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints -> - val pixels = inset.roundToPx() - val width = constraints.maxWidth + pixels * 2 - val height = constraints.maxHeight + pixels * 2 - val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height)) - layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) } -} - -private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache { - var hue = 0 - name.forEach { hue = (hue * 31 + it.code) % 360 } - val values = Regex("([\\d.]+)%").findAll(token).map { it.groupValues[1].toFloat() / 100 }.toList() - val angle = Math.toRadians(Regex("(\\d+)deg").find(token)!!.groupValues[1].toDouble()) - val dx = kotlin.math.sin(angle).toFloat() - val dy = -kotlin.math.cos(angle).toFloat() - val half = (kotlin.math.abs(size.width * dx) + kotlin.math.abs(size.height * dy)) / 2 - val vector = Offset(dx * half, dy * half) - val middle = Offset(size.width / 2, size.height / 2) - val brush = Brush.linearGradient(listOf(Color.hsl(hue.toFloat(), values[0], values[1]), Color.hsl(hue.toFloat(), values[2], values[3])), - start = middle - vector, end = middle + vector) - onDrawBehind { drawRect(brush) } -} - -private fun Modifier.titleAmbientBackground(): Modifier = drawWithCache { - val values = Regex("([\\d.]+)%").findAll(D.heroAmbientBackground).map { it.groupValues[1].toFloat() / 100 }.toList() - val shader = android.graphics.RadialGradient(0f, 0f, 1f, intArrayOf(C.surface3.toArgb(), C.bg.toArgb()), null, android.graphics.Shader.TileMode.CLAMP) - shader.setLocalMatrix(android.graphics.Matrix().apply { - setScale(size.width * values[0], size.height * values[1]) - postTranslate(size.width * values[2], size.height * values[3]) - }) - val brush = ShaderBrush(shader) - onDrawBehind { drawRect(brush) } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt deleted file mode 100644 index fd581f12..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.Dp - -/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ -@Composable -fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, - height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { - val shape = RoundedCornerShape(WolfskinTokens.Radius.card) - Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { - Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt deleted file mode 100644 index ee5054c5..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ -@Composable -fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { - val focusOwner = LocalTitleFocusOwner.current - val background = if (option.primary) C.fg else if (overArtwork) { - if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary - } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary - val foreground = if (option.primary) C.onFocus else C.fg - val shape = RoundedCornerShape(WolfskinTokens.Radius.button) - val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) - Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) - .background(background, shape) - .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) - .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), - horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { - if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) - if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) - } -} - - -/** A radio remains visibly selected independently of the inverse focused row. */ -@Composable -fun TitleRadio(selected: Boolean, focused: Boolean) { - val color = if (focused) C.onFocus else C.fg - Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape) - .then(if (focused || !selected) Modifier.border(D.radioBorder, color.copy(alpha = if (focused) .5f else .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) { - if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape)) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt deleted file mode 100644 index 903bf508..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ /dev/null @@ -1,117 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.platform.testTag -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Film, series and episode share one copy block and one fixed options-caption band. */ -@Composable -fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { - if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } - val item = state.data.item - val type = if (item.kind == "movie") "Movie" else "Series" - CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { - Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { - Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) - Text(buildString { - append("$type · ${if (item.held) "In your library" else "Not in your library"}") - if (item.held && state.watched) append(" · Watched") - if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library") - if (state.data.hidden) append(" · Hidden from your picks") - }, color = C.accentText, style = TitleTextStyles.dLine) - } - Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading")) - Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) { - item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } - if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) - if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - } - state.data.requestedStage?.let { stage -> - val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } - Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { - Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) - Text(stage, color = color, style = TitleTextStyles.label) - Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) - } - } - Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) - if (!item.held && state.data.connection != TitleConnection.SignedIn) { - Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", - color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) - } - state.data.playTarget?.let { target -> - Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { - if (target.resumeSeconds > 0) { - Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { - val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f - Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) - } - Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) - } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress) - } - } - Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, - Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, - onFocus = { state.focus("header", index) }) { state.click("header", index) } } - } - if (state.options.any { it.icon }) { - val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } - Text(buildAnnotatedString { - if (option != null) { - withStyle(SpanStyle(color = C.fg, fontWeight = WolfskinTokens.Type.label.fontWeight)) { append(option.label) } - if (option.note.isNotEmpty()) append(" · ${option.note}") - } - }, color = C.muted, style = D.iconCaption, modifier = Modifier.padding(top = D.captionTop).height(L.iconCaptionLine)) - } - } -} - -/** A no-copy load has no focusable content and never draws a spinner. */ -@Composable -fun TitleHeaderSkeleton(modifier: Modifier = Modifier) { - Column(modifier.offset(L.safeSide, L.detailCopyTop)) { - Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) - Spacer(Modifier.height(D.skeletonTitleGap)) - Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) - Spacer(Modifier.height(D.skeletonFactsGap)) - Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) - Spacer(Modifier.height(D.skeletonOverviewGap)) - Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) - Spacer(Modifier.height(D.overviewGap)) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) - repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt deleted file mode 100644 index 103e8818..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Immutable - -/** Screen data is independent of Jellyfin and Conductarr wire objects. */ -@Immutable -data class TitleItem( - override val id: String, - val name: String, - val kind: String, - val year: Int? = null, - val runtimeSeconds: Long = 0, - val seasons: Int = 0, - val overview: String = "", - val genres: List = emptyList(), - override val poster: String? = null, - val backdrop: String? = null, - val held: Boolean = false, - val watched: Boolean = false, - val resumeSeconds: Long = 0, - val seriesId: String? = null, - val seriesName: String? = null, - val season: Int = 0, - val episode: Int = 0, - override val ratings: List = emptyList(), - val character: String? = null, - val otherOwners: List = emptyList(), - val isPosterBackdrop: Boolean = false, - val tmdbId: Long? = null, -) : PosterContent { - override val posterTitle: String get() = name - override val posterIsMovie: Boolean get() = kind == "movie" - override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f - override val badge: String? get() = if (held) "held" else null - val episodeLabel: String get() = "S$season E$episode" - val runtime: String get() { - val minutes = (runtimeSeconds + 30) / 60 - return if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" - } -} - -/** A cast identity can open a performer page without borrowing a title's identity. */ -@Immutable -data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "") - -/** Stream indexes remain the server's indexes, including non-contiguous track lists. */ -@Immutable -data class TitleTrack(val index: Int, val label: String, val isDefault: Boolean = false) - -/** Playback options describe the actual play target, not a season or recommendation stub. */ -@Immutable -data class TitleVersion( - val id: String, - val name: String, - val video: String, - val bitrate: String, - val size: String, - val container: String, - val audio: List, - val subtitles: List, -) - -enum class TitleReadStatus { Ready, Loading, Unavailable } -enum class TitleConnection { SignedIn, SignedOut, Offline } -enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt } - -/** A title read includes related titles and the selected season; reads happen outside composition. */ -@Immutable -data class TitlePageData( - val item: TitleItem, - val playTarget: TitleItem? = null, - val watchTarget: TitleItem = item, - val versions: List = emptyList(), - val selectedVersion: String? = null, - val selectedAudio: Int? = null, - val selectedSubtitle: Int = -1, - val trailerUrl: String? = null, - val cast: List = emptyList(), - val castStatus: TitleReadStatus = TitleReadStatus.Ready, - val related: List = emptyList(), - val episodes: List = emptyList(), - val status: TitleReadStatus = TitleReadStatus.Ready, - val connection: TitleConnection = TitleConnection.SignedOut, - val requestedStage: String? = null, - val requestActor: String? = null, - val hidden: Boolean = false, - val pick: Boolean = false, - val savedAt: String? = null, - val seasonNumbers: List = List(item.seasons) { it + 1 }, - val selectedSeason: Int? = null, - val episodesStatus: TitleReadStatus = TitleReadStatus.Ready, - val requestsAllowed: Boolean = true, - val requestFor: String? = null, -) - -/** Effects are handled by the app's authenticated facade and navigation layer. */ -sealed interface TitleAction { - data class Play(val item: TitleItem, val restart: Boolean = false) : TitleAction - data class Version(val item: TitleItem, val version: TitleVersion) : TitleAction - data class Track(val item: TitleItem, val version: TitleVersion, val audio: Int?, val subtitle: Int) : TitleAction - data class Watched(val item: TitleItem, val watched: Boolean) : TitleAction - data class Trailer(val url: String) : TitleAction - data class Person(val person: TitlePerson) : TitleAction - data class OpenTitle(val item: TitleItem) : TitleAction - data class Season(val number: Int) : TitleAction - data class Request(val item: TitleItem, val forSomeoneElse: Boolean) : TitleAction - data class Hide(val item: TitleItem, val hidden: Boolean) : TitleAction - data class Requests(val item: TitleItem) : TitleAction - data object Retry : TitleAction - data object Pop : TitleAction -} - -/** The options row distinguishes compact icons from labelled primary and secondary pills. */ -@Immutable -data class TitleOption(val id: String, val label: String, val glyph: String, val note: String = "", val primary: Boolean = false, val icon: Boolean = true, val on: Boolean = false) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt deleted file mode 100644 index 3a87dcdd..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ /dev/null @@ -1,170 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.Row -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.size -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.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.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.tv.material3.Text -import androidx.compose.ui.draw.dropShadow -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.layout.layout -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Sheets sit above navigation and consume pointer and key input across the entire scrim. */ -@Composable -fun TitleOptionsSheet(state: TitleState, shell: ShellState) { - val focus = remember { FocusRequester() } - LaunchedEffect(state.sheet) { focus.requestFocus() } - val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } - .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } - .focusRequester(focus).onPreviewKeyEvent { event -> - val key = readShellKey(event) - key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { - CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) - .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), - gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { - when (state.sheet) { - TitleSheet.Version -> VersionChoices(state) - TitleSheet.Tracks -> TrackChoices(state) - TitleSheet.MediaInfo -> MediaInformation(state) - TitleSheet.ConfirmWatched -> WatchedConfirmation(state) - TitleSheet.HiddenReceipt -> HiddenReceipt(state) - null -> Unit - } - } - } -} - -@Composable -private fun VersionChoices(state: TitleState) { - Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) - SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files") - state.data.versions.forEachIndexed { index, version -> - PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } - } - Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption)) -} - -@Composable -private fun TrackChoices(state: TitleState) { - val version = state.version ?: return - Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) - SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}") - Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) - version.audio.forEachIndexed { index, track -> - PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } - } - Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) - val start = version.audio.size - PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } - version.subtitles.forEachIndexed { index, track -> - val position = start + index + 1 - PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) } - } - val done = start + version.subtitles.size + 1 - TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.sheetButtonTop).sheetFocused(state.sheetIndex == done), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) } -} - -@Composable -private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { - val foreground = if (focused) C.onFocus else C.fg - val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) - Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) - .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), - horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { - CssColumn(Modifier.weight(1f)) { - Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle)) - if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption)) - } - TitleRadio(selected, focused) - } -} - -@Composable -private fun MediaInformation(state: TitleState) { - val version = state.version ?: return - Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) - SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name) - MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known"))) - MediaInfoSection("Video", listOf("Stream" to version.video)) - MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") }) - MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label }) - TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, Modifier.padding(top = D.sheetButtonTop).sheetFocused(true), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } -} - -@Composable -private fun MediaInfoSection(label: String, values: List>) { - Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.exactLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) - CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) -> - Row(Modifier.fillMaxWidth().exactLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { - Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth)) - Text(value, color = C.fg, style = TitleTextStyles.caption) - } - } } -} - -@Composable -private fun WatchedConfirmation(state: TitleState) { - val verb = if (state.watched) "unwatched" else "watched" - Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.exactLineBox(TitleTextStyles.sheetTitle)) - SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.") - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } - } -} - -@Composable -private fun SheetSubtitle(text: String) { - Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom)) -} - -@Composable -private fun HiddenReceipt(state: TitleState) { - Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop), - verticalArrangement = Arrangement.spacedBy(D.receiptGap)) { - Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("check", C.fg, Modifier.size(D.receiptIcon)) - Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle) - } - Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body) - } - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt deleted file mode 100644 index 85f847f5..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ /dev/null @@ -1,209 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import kotlinx.coroutines.delay -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.Modifier -import androidx.compose.ui.Alignment -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.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.unit.Dp -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** The shared shell remains authoritative; title sheets overlay it and trap all input. */ -@Composable -fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - val view = shell.view - LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } - Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> - // No content target exists during an initial load; nav Down must not enter a skeleton. - if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false - else when (readShellKey(event)) { - ShellKey.Down -> true - ShellKey.Back -> { - if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell) - true - } - else -> false - } - }) { - val focusOwner = remember(shell) { { shell.navFocused = false } } - androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { - ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } - } - if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) - if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } - } -} - -/** Playback and person reads share one calm, timed notice above the persistent nav. */ -@Composable -internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDismiss: () -> Unit) { - LaunchedEffect(message) { - if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } - } - if (message != null) RecoveryToast(message, modifier) -} - -/** One keyed vertical frame renders the exact header, season, cast and related order. */ -@Composable -fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - val density = LocalDensity.current - val list = shell.view.listState - var wheelPending by remember { mutableStateOf(false) } - val focus = remember { FocusRequester() } - LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { - if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() - } - val displayedRows = remember(state.data) { - buildList { - add("header") - if (state.data.status != TitleReadStatus.Ready) return@buildList - if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) { - add("seasons") - if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes") - } - if (state.data.castStatus == TitleReadStatus.Loading || state.data.cast.isNotEmpty()) add("cast") - if (state.data.related.isNotEmpty()) add("related") - } - } - val rowOffsets = remember(displayedRows, density) { - FloatArray(displayedRows.size + 1).apply { - displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } } - } - } - val position by remember(list, rowOffsets) { derivedStateOf { - rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset - } } - LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) { - if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) - val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) - val y = rowOffsets[index] - val height = with(density) { titleRowHeight(state.row).toPx() } - val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f - else y - with(density) { L.pinnedLine.toPx() } - if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) - } - val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } - val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } - val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } } - TitleRowPrefetch(state.data, nextArtRow) - Box(modifier.focusRequester(focus).testTag("title-page").semantics { stateDescription = "${state.data.item.id}:${state.row}:${state.column}:${state.sheet}" } - .titleWheel { if (state.data.status == TitleReadStatus.Ready) { wheelPending = true; shell.navFocused = false } } - .onPreviewKeyEvent { event -> - val key = readShellKey(event) - if (key != null && key != ShellKey.Back && event.type == KeyEventType.KeyDown && wheelPending) { - val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { - it.key in state.rows && it.offset + it.size > with(density) { L.pinnedLine.roundToPx() } - }?.key as? String ?: "header" - state.focus(visible, state.columns[visible] ?: 0) - wheelPending = false - if (key != ShellKey.Ok) return@onPreviewKeyEvent true - } - key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { - // This remains visible behind the scrolled rows; it is not an off-screen header image. - TitleBackdrop(state.data.item, Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) - if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) - else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { - displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { - val active = state.row == id && !shell.navFocused && state.sheet == null - when (id) { - "header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) } - "seasons" -> TitleSeasons(state, active) - "episodes" -> TitleMediaShelf(state, true, active) - "cast" -> TitleCast(state, active) - "related" -> TitleMediaShelf(state, false, active) - } - } } - item(key = "end-padding", contentType = "spacer") { - Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding))) - } - } - } -} - -private fun titleRowHeight(row: String): Dp = when (row) { - "header" -> L.row_dhead - "seasons" -> L.row_chips - "episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter - "cast" -> D.castHeight - else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter -} - -@Composable -private fun TitleSeasons(state: TitleState, active: Boolean) { - val focusOwner = LocalTitleFocusOwner.current - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { - items(state.data.seasonNumbers.size, key = { "season-${state.data.seasonNumbers[it]}" }, contentType = { "chip" }) { index -> - val number = state.data.seasonNumbers[index] - val focused = active && state.column == index - val selected = state.season == number - val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) - Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) - .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) - .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) - .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) - } - } - } -} - -@Composable -private fun TitleReadError(state: TitleState) { - Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth) - .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .padding(WolfskinTokens.SavedIndicator.border) - .padding(horizontal = D.errorSide, vertical = D.errorVertical)) { - Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("alert", C.warn, Modifier.size(D.receiptIcon)) - Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle) - } - Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } } - } -} - -internal val titleFrameScrollSpec = tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt deleted file mode 100644 index ab763b8a..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePointer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput - -/** Pointing at owned content exits nav focus through the same shell state as a remote Down. */ -val LocalTitleFocusOwner = compositionLocalOf<(() -> Unit)?> { null } - -/** Hover changes logical focus without activating a button or arming an OK press. */ -@Composable -fun Modifier.titlePointerFocus(onFocus: (() -> Unit)?): Modifier { - if (onFocus == null) return this - val latest by rememberUpdatedState(onFocus) - val owner = LocalTitleFocusOwner.current - return pointerInput(owner) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - if (event.type == PointerEventType.Enter) { owner?.invoke(); latest() } - } - } - } -} - -/** Observe wheel input without consuming the frame's native scroll gesture. */ -@Composable -fun Modifier.titleWheel(onScroll: () -> Unit): Modifier { - val latest by rememberUpdatedState(onScroll) - return pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - if (awaitPointerEvent().type == PointerEventType.Scroll) latest() - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt deleted file mode 100644 index 641fead0..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import coil3.request.Disposable - -/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */ -@Composable -internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { - val context = LocalContext.current - if (row == null || data.status != TitleReadStatus.Ready) return - if (LocalTitleBitmaps.current.isNotEmpty()) return - val loader = WolfskinImages.loader(context) - DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) { - val requests = ArrayList() - fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) { - if (path != null) requests.add(loader.enqueue(WolfskinImages.request(context, path, width, height, poster))) - } - when (row) { - "cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots)) - queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) - "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { - val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight) - } - "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) - queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) - } - onDispose { requests.forEach { it.dispose() } } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt deleted file mode 100644 index d6c46049..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.justinvisser.wolfskin.design - -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.remember -import androidx.compose.ui.Modifier - -/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */ -@Composable -internal fun Modifier.sheetFocused(focused: Boolean): Modifier { - val request = remember { BringIntoViewRequester() } - LaunchedEffect(focused) { if (focused) request.bringIntoView() } - return bringIntoViewRequester(request) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt deleted file mode 100644 index 15a74880..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ /dev/null @@ -1,169 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.CubicBezierEasing -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.zIndex -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D - -/** Cast occupies the first shelf after films, and follows season episodes on series. */ -@Composable -fun TitleCast(state: TitleState, active: Boolean) { - val track = rememberLazyListState() - val focusOwner = LocalTitleFocusOwner.current - val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } - LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } - Box(Modifier.height(D.castHeight)) { - Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), - contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { - if (state.data.castStatus == TitleReadStatus.Loading) { - items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { - Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) - } - } else itemsIndexed(state.data.cast, key = { _, person -> person.id }, contentType = { _, _ -> "person" }) { index, person -> - val focused = active && state.column == index - Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) - .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } - .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { - PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape)) - Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { - Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - } - } - } - } - } -} - -/** Episode and related shelves are lazy, keyed and keep their focused card above adjacent art. */ -@Composable -fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { - val titles = if (episodes) state.data.episodes else state.data.related - val id = if (episodes) "episodes" else "related" - val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW - val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH - val track = rememberLazyListState() - val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots - val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } - LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } - Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { - Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) - .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2), - contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { - if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { - items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { - Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) - } - } else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item -> - TitlePoster(item, active && state.column == index, Modifier.width(width) - .onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) }, - episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) } - } - } - } -} - -/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ -@Composable -fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { - val focusOwner = LocalTitleFocusOwner.current - Column(modifier.zIndex(if (focused) 1f else 0f)) { - if (!tile) { - val bitmaps = LocalTitleBitmaps.current - val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } - LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, - modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) - } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) - .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { - TitleTileArtwork(item) - Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) - .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { - Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - val progress = item.progress - if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) - .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { - Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) - } - } - androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) { - LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) - } - } - if (!tile) { - val availability = androidx.compose.runtime.remember(item, catalogue) { - if (catalogue) { - if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } - } else if (item.watched) "Watched" - else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" - else null - } - val facts = androidx.compose.runtime.remember(item, availability) { - listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), - if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", - availability).joinToString(" · ") - } - Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { - Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), - accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) - } - } - } -} - -/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ -private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { - val first = firstVisibleItemIndex - val next = when { - column < first -> column - column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0) - else -> first - } - if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec) -} - -private val titleTrackScrollSpec = tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt deleted file mode 100644 index 242e0e6b..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSlotArtwork.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -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.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H - -/** Slot dimensions adapt to the shared exact-pixel artwork request, never another image loader. */ -@Composable -private fun SlotImage(source: String, width: Dp, height: Dp, modifier: Modifier = Modifier, poster: Boolean = false, colorFilter: ColorFilter? = null, onError: () -> Unit = {}) { - TitleArtwork(source, (width.value * 2).toInt(), (height.value * 2).toInt(), modifier, poster, colorFilter, onError) -} - -/** Poster-only landscapes retain the upright one-sheet above the same dimmed image wash. */ -@Composable -fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height: Dp, modifier: Modifier = Modifier, - backdrop: String? = null, posterOnly: Boolean = false, landscape: Boolean = false, hero: Boolean = false) { - val source = if (landscape) backdrop ?: poster else poster - val failed = remember(source) { androidx.compose.runtime.mutableStateOf(false) } - val missing = source == null || failed.value - Box(modifier.background(C.surface2)) { - if (!missing) { - if (landscape && (posterOnly || backdrop == null) && poster != null) { - val wash = remember(hero) { - val matrix = ColorMatrix().apply { setToSaturation(if (hero) 1.2f else 1f) } - val brightness = if (hero) .55f else .5f - for (index in 0 until 15) matrix.values[index] *= brightness - ColorFilter.colorMatrix(matrix) - } - val inset = if (hero) H.heroWashInset else H.cardWashInset - val washModifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true) - .requiredSize(width + inset * 2, height + inset * 2) - .blur(if (hero) H.heroBlur else H.cardBlur) - .graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } } - SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true }) - val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset - val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f) - val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop) - SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true }) - } else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) - } else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape) - else HomeAmbient(Modifier.fillMaxSize()) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt deleted file mode 100644 index 2e5da10c..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ /dev/null @@ -1,291 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -/** One focus controller drives the drawn screen and Android key-event replays. */ -class TitleState( - initial: TitlePageData, - val person: ShellPerson, - private val onAction: (TitleAction) -> Unit, -) { - var data by mutableStateOf(initial) - private set - var row by mutableStateOf("header") - var column by mutableStateOf(0) - val columns = mutableStateMapOf() - private val centers = HashMap>() - fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x } - fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) } - fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) } - var sheet by mutableStateOf(null) - private set - var sheetIndex by mutableStateOf(0) - var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1) - var versionId by mutableStateOf(initial.selectedVersion) - var audioIndex by mutableStateOf(initial.selectedAudio) - var subtitleIndex by mutableStateOf(initial.selectedSubtitle) - var watched by mutableStateOf(initial.watchTarget.watched) - var toast by mutableStateOf(null) - private var armedCard: String? = null - private var okHeld = false - val target: TitleItem? get() = data.playTarget ?: data.item.takeIf { it.kind == "movie" && it.held } - val version: TitleVersion? get() = data.versions.firstOrNull { it.id == versionId } ?: data.versions.firstOrNull() - val audio: TitleTrack? get() = version?.audio?.firstOrNull { it.index == audioIndex } ?: version?.audio?.firstOrNull { it.isDefault } ?: version?.audio?.firstOrNull() - val subtitle: TitleTrack? get() = version?.subtitles?.firstOrNull { it.index == subtitleIndex } - val rows: List by derivedStateOf { buildList { - add("header") - if (data.status != TitleReadStatus.Ready) return@buildList - if (data.item.held && data.item.kind == "tv" && data.item.seasons > 0) { - add("seasons") - if (data.episodesStatus == TitleReadStatus.Ready && data.episodes.isNotEmpty()) add("episodes") - } - if (data.castStatus == TitleReadStatus.Ready && data.cast.isNotEmpty()) add("cast") - if (data.related.isNotEmpty()) add("related") - } } - val options: List by derivedStateOf { titleOptions(this) } - - /** Refresh keeps the selected row and file when they still exist. */ - fun update(value: TitlePageData) { - val wasLoading = data.status == TitleReadStatus.Loading - val pendingWatched = watched != data.watchTarget.watched - val pendingVersion = versionId != data.selectedVersion - val pendingAudio = audioIndex != data.selectedAudio - val pendingSubtitle = subtitleIndex != data.selectedSubtitle - data = value - if (wasLoading || season !in value.seasonNumbers) season = value.selectedSeason ?: value.playTarget?.season?.takeIf { it in value.seasonNumbers } ?: value.seasonNumbers.firstOrNull() ?: 1 - if (!pendingWatched) watched = value.watchTarget.watched - if (!pendingVersion) versionId = value.selectedVersion - if (!pendingAudio) audioIndex = value.selectedAudio - if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle - if (row !in rows) { row = "header"; column = 0 } - column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0)) - } - - /** Pointer focus and D-pad use the same row memory. */ - fun focus(targetRow: String, targetColumn: Int) { - columns[row] = column - row = targetRow - column = targetColumn - columns[row] = column - } - /** Pointer clicks activate the same state transition as a completed remote press. */ - fun click(targetRow: String, targetColumn: Int) { - focus(targetRow, targetColumn) - activate() - } - - - /** Keep the last confirmed selections separate from immediate, optimistic UI feedback. */ - fun confirmPlayback(action: TitleAction) { - data = when (action) { - is TitleAction.Version -> data.copy(selectedVersion = action.version.id, selectedAudio = null, selectedSubtitle = -1) - is TitleAction.Track -> data.copy(selectedVersion = action.version.id, selectedAudio = action.audio, selectedSubtitle = action.subtitle) - is TitleAction.Watched -> data.copy(watchTarget = data.watchTarget.copy(watched = action.watched)) - else -> data - } - } - - /** A failed older write cannot undo a newer selection still waiting to save. */ - fun rejectPlayback(action: TitleAction) { - when (action) { - is TitleAction.Version -> if (versionId == action.version.id) { - versionId = data.selectedVersion; audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle - } - is TitleAction.Track -> if (audioIndex == action.audio && subtitleIndex == action.subtitle) { - audioIndex = data.selectedAudio; subtitleIndex = data.selectedSubtitle - } - is TitleAction.Watched -> if (watched == action.watched) watched = data.watchTarget.watched - else -> Unit - } - } - - /** Key-up can only activate the same card that armed the first key-down. */ - fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { - if (key == ShellKey.Ok) { - if (!down) { - okHeld = false - val card = "$row:$column" - if (armedCard == card && sheet == null && !shell.navFocused) activate() - armedCard = null - return true - } - if (repeat > 0 || okHeld) return true - okHeld = true - if (sheet == null && (row == "cast" || row == "related" || row == "episodes")) { - armedCard = "$row:$column" - return true - } - } else { - if (!down) return true - armedCard = null - } - if (sheet != null) { sheetKey(key); return true } - when (key) { - ShellKey.Back -> back(shell) - ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1) - ShellKey.Down -> moveRow(1) - ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0)) - ShellKey.Right -> focus(row, (column + 1).coerceAtMost((rowSize(row) - 1).coerceAtLeast(0))) - ShellKey.Ok -> activate() - } - return true - } - - /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ - fun back(shell: ShellState) { - when { - sheet != null -> sheet = null - shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) - else -> onAction(TitleAction.Pop) - } - } - - /** Pointer selection stays inside the modal's row order. */ - fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) } - - private fun moveRow(delta: Int) { - val index = (rows.indexOf(row) + delta).coerceIn(0, rows.lastIndex) - val next = rows[index] - val x = centers[row]?.get(column) - val nearest = if (x != null) centers[next]?.entries?.minByOrNull { kotlin.math.abs(it.value - x) }?.key else null - focus(next, (columns[next] ?: nearest ?: column).coerceAtMost((rowSize(next) - 1).coerceAtLeast(0))) - } - - private fun rowSize(id: String): Int = when (id) { - "header" -> options.size - "seasons" -> data.seasonNumbers.size - "episodes" -> data.episodes.size - "cast" -> data.cast.size - "related" -> data.related.size - else -> 0 - } - - private fun activate() { - when (row) { - "cast" -> data.cast.getOrNull(column)?.let { onAction(TitleAction.Person(it)) } - "related" -> data.related.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "episodes" -> data.episodes.getOrNull(column)?.let { onAction(TitleAction.OpenTitle(it)) } - "seasons" -> { season = data.seasonNumbers[column]; onAction(TitleAction.Season(season)) } - "header" -> options.getOrNull(column)?.let { activateOption(it.id) } - } - } - - private fun activateOption(id: String) { - when (id) { - "play", "restart" -> target?.let { onAction(TitleAction.Play(it, id == "restart")) } - "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } - "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } - "more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo } - "watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched() - "trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) } - "seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0)) - "retry" -> onAction(TitleAction.Retry) - "back" -> onAction(TitleAction.Pop) - "request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request")) - "status" -> onAction(TitleAction.Requests(data.item)) - "hide", "unhide" -> { - val hidden = id == "hide" - data = data.copy(hidden = hidden) - onAction(TitleAction.Hide(data.item, hidden)) - if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks." - } - } - } - - private fun toggleWatched() { - watched = !watched - onAction(TitleAction.Watched(data.watchTarget, watched)) - } - - private fun sheetKey(key: ShellKey) { - if (key == ShellKey.Back) { sheet = null; return } - val current = version - val size = when (sheet) { - TitleSheet.Version -> data.versions.size - TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2 - TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2 - else -> 1 - } - when (key) { - ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0) - ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1) - ShellKey.Ok -> when (sheet) { - TitleSheet.Version -> { - val selected = data.versions[sheetIndex] - versionId = selected.id - audioIndex = null - subtitleIndex = -1 - target?.let { onAction(TitleAction.Version(it, selected)) } - sheet = null - } - TitleSheet.Tracks -> if (current != null) selectTrack(current) - TitleSheet.ConfirmWatched -> { if (sheetIndex == 0) toggleWatched(); sheet = null } - TitleSheet.HiddenReceipt -> { - sheet = null - if (sheetIndex == 0) { - data = data.copy(hidden = false) - onAction(TitleAction.Hide(data.item, false)) - } else onAction(TitleAction.Pop) - } - else -> sheet = null - } - } - } - - private fun selectTrack(current: TitleVersion) { - when { - sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index - sheetIndex == current.audio.size -> subtitleIndex = -1 - sheetIndex <= current.audio.size + current.subtitles.size -> subtitleIndex = current.subtitles[sheetIndex - current.audio.size - 1].index - else -> { sheet = null; return } - } - target?.let { onAction(TitleAction.Track(it, current, audio?.index, subtitleIndex)) } - } -} - -/** Button order follows the file, resume, ownership and authentication facts, never synthetic availability. */ -fun titleOptions(state: TitleState): List = buildList { - val data = state.data - val item = data.item - val target = state.target - if (data.status != TitleReadStatus.Ready) { - if (data.status == TitleReadStatus.Unavailable) { - add(TitleOption("retry", "Try again", "", primary = true, icon = false)) - add(TitleOption("back", "Back", "", icon = false)) - } - return@buildList - } - if (item.held) { - if (target != null) { - val verb = if (target.resumeSeconds > 0) "Resume" else "Play" - add(TitleOption("play", verb + if (target.kind == "episode") " ${target.episodeLabel}" else "", "play", primary = true, icon = false)) - if (target.resumeSeconds > 0) add(TitleOption("restart", "Restart", "restart", "play from the beginning")) - if (data.versions.size > 1) add(TitleOption("version", "Version", "version", state.version?.name.orEmpty())) - add(TitleOption("tracks", "Audio and subtitles", "tracks", "${state.audio?.label.orEmpty()} · subtitles ${state.subtitle?.label ?: "off"}")) - } - val series = data.watchTarget.kind == "tv" - add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched)) - if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer")) - if (target != null) add(TitleOption("more", "More", "more", "Media info")) - if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false)) - if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false)) - } else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) { - add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) - } else { - add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) - add(TitleOption("other", "For someone else", "users", icon = false)) - if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) - else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) - } -} - -internal fun titleStageGlyph(stage: String): String = when (stage) { - "Ready" -> "check" - "Failed" -> "alert" - "Downloading", "Almost ready" -> "download" - else -> "clock" -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt deleted file mode 100644 index 69465238..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.LineHeightStyle - -/** Explicit styles retain CSS leading without changing shared nav or library typography. */ -internal object TitleTextStyles { - private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) - private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes) - - val displayDetail = WolfskinTokens.Type.displayDetail.withLineBoxes() - val dLine = WolfskinTokens.Title.dLine.withLineBoxes() - val progress = WolfskinTokens.Title.dLine.withLineBoxes() - val castName = WolfskinTokens.Title.castName.withLineBoxes() - val body = WolfskinTokens.Type.body.withLineBoxes() - val caption = WolfskinTokens.Type.caption.withLineBoxes() - val label = WolfskinTokens.Type.label.withLineBoxes() - val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes() - val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes() - val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes() - val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes() - val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes() - val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes() - val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes() -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt deleted file mode 100644 index 48d6c5e6..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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 -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.requiredWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C -import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L -import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S - -/** The picker covers the nav and exposes Settings only below the final person. */ -@Composable -fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize().background(C.bg)) { - Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) - if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state) - } -} - -@Composable -private fun PeoplePicker(state: ShellState) { - Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { - Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) - } - Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { - state.people.forEachIndexed { index, person -> - val focused = !state.gearFocused && state.pickerIndex == index - Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { - state.focusPerson(index); state.key(ShellKey.Ok) - }, horizontalAlignment = Alignment.CenterHorizontally) { - Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, - Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) - Spacer(Modifier.height(S.pickerNameGap)) - Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) - } - } - } - val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) - val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 - Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing) - .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) - if (state.gearFocused) { - Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) { - Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) - } - } - } -} - -@Composable -private fun RecoveryPicker(state: ShellState) { - val loading = state.peopleStatus == PeopleStatus.Loading - val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop - Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) { - Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) - } - if (loading) { - Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { - repeat(state.people.size) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) - Spacer(Modifier.height(S.pickerNameGap)) - Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius))) - } - } - } - } else { - Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop) - .size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) { - Glyph("alert", C.warn, Modifier.size(S.panelIcon)) - Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) - } - Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body) - } - } - Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) { - if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) } - PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) } - } -} - -/** Recovery actions remain focusable while profiles cannot be read. */ -@Composable -fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, - textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { - val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary - Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) - .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) - .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) - .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { - if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt deleted file mode 100644 index 4e0822bf..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.content.Context -import coil3.ComponentRegistry -import coil3.ImageLoader -import coil3.disk.DiskCache -import coil3.intercept.Interceptor -import coil3.disk.directory -import coil3.request.crossfade -import coil3.memory.MemoryCache -import coil3.request.ImageRequest -import coil3.request.allowRgb565 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit - -/** Wolfskin screens share one bounded loader; hidden-switch screens retain the upstream loader. */ -object WolfskinImages { - private var application: Context? = null - private var loader: ImageLoader? = null - private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null - private const val MEBIBYTE = 1024L * 1024L - private const val IMAGE_READS = 4 - private val readPermits = Semaphore(IMAGE_READS) - - /** The app supplies authenticated networking without exposing its server/session to design code. */ - @Synchronized - fun configureNetwork(context: Context, configure: ComponentRegistry.Builder.() -> Unit) { - val owner = context.applicationContext - if (application === owner && networkConfiguration === configure) return - loader?.shutdown() - loader = null - application = owner - networkConfiguration = configure - } - - @Synchronized - fun loader(context: Context): ImageLoader { - val owner = context.applicationContext - if (application !== owner) { - loader?.shutdown() - loader = null - application = owner - networkConfiguration = null - } - if (loader == null) { - val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS) - // Parent components may attach a Jellyfin token to every HTTP host. - val builder = ImageLoader.Builder(owner) - loader = builder - .memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() } - .diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() } - .components { - // Coil replaces this registry; install networking and bounds together. - networkConfiguration?.invoke(this) - add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) - } - .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() - } - return loader!! - } - - fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = - ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build() - - fun prefetch(context: Context, row: HomeRow) { - val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 } - val width = (row.cardWidth.value * 2).toInt() - val height = (row.cardHeight.value * 2).toInt() - row.cards.take(count).forEach { title -> - val poster = row.cardKind == HomeCardKind.Poster - val source = if (poster) title.poster else title.backdrop ?: title.poster - if (source != null) loader(context).enqueue(request(context, source, width, height, poster)) - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt deleted file mode 100644 index d3e155a8..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.runtime.Composable -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.darkColorScheme - -/** Both Compose Material families use the same opaque TV palette for hand-offs. */ -object WolfskinColors { - private val c = WolfskinTokens.Color - val darkScheme = darkColorScheme( - primary = c.accent, onPrimary = c.onFocus, - primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, - secondary = c.accent, onSecondary = c.onFocus, - secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, - tertiary = c.accent, onTertiary = c.onFocus, - tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, - background = c.bg, onBackground = c.fg, - surface = c.bg, onSurface = c.fg, - surfaceVariant = c.surface2, onSurfaceVariant = c.fg, - border = c.accent, - ) - val darkSchemeMaterial = androidx.compose.material3.darkColorScheme( - primary = c.accent, onPrimary = c.onFocus, - primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, - secondary = c.accent, onSecondary = c.onFocus, - secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, - tertiary = c.accent, onTertiary = c.onFocus, - tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, - background = c.bg, onBackground = c.fg, - surface = c.bg, onSurface = c.fg, - surfaceVariant = c.surface2, onSurfaceVariant = c.fg, - ) -} - -/** Upstream content inherits tokens without a fork-specific theme enum. */ -@Composable -fun WolfskinTheme(content: @Composable () -> Unit) { - androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) { - MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt deleted file mode 100644 index 4a9ec01c..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ /dev/null @@ -1,672 +0,0 @@ -// Generated by generate_tokens.py; edit the JSON sources instead. -package org.justinvisser.wolfskin.design - -import androidx.compose.ui.graphics.Color as ComposeColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em -import androidx.compose.ui.unit.sp - -/** The locked TV design, converted at the source density. */ -object WolfskinTokens { - object Color { - val bg = ComposeColor(0xFF07070A) - val surface = ComposeColor(0xFF13131A) - val surface2 = ComposeColor(0xFF1B1B25) - val surface3 = ComposeColor(0xFF252532) - val fg = ComposeColor(0xFFF4F4F6) - val muted = ComposeColor(0xBDF4F4F6) - val dim = ComposeColor(0x8FF4F4F6) - val border = ComposeColor(0x1AFFFFFF) - val accent = ComposeColor(0xFFCC9FFF) - val accentSoft = ComposeColor(0xFF2C1A3F) - val accentText = ComposeColor(0xFFE4CBFF) - val ok = ComposeColor(0xFF89DA9B) - val warn = ComposeColor(0xFFF4C26A) - val fail = ComposeColor(0xFFF97770) - val onFocus = ComposeColor(0xFF0B0B10) - val buttonSecondary = ComposeColor(0x24FFFFFF) - val buttonSecondaryFocused = ComposeColor(0x42FFFFFF) - val titleButtonSecondary = ComposeColor(0xC72C2C38) - val titleButtonSecondaryFocused = ComposeColor(0xE6464654) - val chipRest = ComposeColor(0x1AFFFFFF) - val navBlobRest = ComposeColor(0x1AFFFFFF) - val listRowRest = ComposeColor(0x0DFFFFFF) - val sheetScrim = ComposeColor(0x9E000000) - val badgeRest = ComposeColor(0xBF07070A) - val badgeWatched = ComposeColor(0xD9F4F4F6) - val progressTrack = ComposeColor(0x38FFFFFF) - val railEmpty = ComposeColor(0x38F4F4F6) - } - object PersonColor { - val justin = ComposeColor(0xFF62C4FF) - val mindel = ComposeColor(0xFFFF9D72) - val shared = ComposeColor(0xFF9BE38F) - } - object Gradient { - const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)" - const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)" - const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))" - const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)" - const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)" - const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))" - const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))" - const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))" - const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" - const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)" - const val cardAmbientFilter = "blur(26px) brightness(.5)" - } - object Type { - val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em) - val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em) - val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em) - val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.em) - val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em) - val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em) - val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) - val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.em) - val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.em) - val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em) - val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em) - val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em) - val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em) - val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) - val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em) - val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800)) - val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800)) - val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) - val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) - val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) - val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800)) - val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) - const val stageLineHeight = 1.3f - } - object Radius { - val card = 8.0.dp - val button = 19.0.dp - val chip = 17.0.dp - val navItem = 16.0.dp - val listRow = 12.0.dp - val panel = 11.0.dp - val menu = 13.0.dp - val heroSheet = 9.0.dp - val cardSheet = 5.0.dp - val thumb = 6.0.dp - val pill = 11.0.dp - val progress = 2.0.dp - } - object Layout { - val safeSide = 48.0.dp - val contentWidth = 864.0.dp - val safeTopPicker = 27.0.dp - val navBand = 75.0.dp - val navPaddingTop = 22.0.dp - val navItemHeight = 32.0.dp - val navItemPaddingX = 13.0.dp - val navItemGap = 6.0.dp - val wordmarkGap = 20.0.dp - val navUnderline = 2.0.dp - val navBlobAvatarPad = 4.0.dp - val navSolidAfter = 20.0.dp - val pinnedLine = 84.0.dp - val windowAhead = 350.0.dp - val rowHeadingBand = 42.0.dp - val rowGapAfter = 34.0.dp - val gridRowGap = 22.0.dp - val row_hero = 410.0.dp - val row_phero = 370.0.dp - val row_section = 75.0.dp - val row_chips = 52.0.dp - val row_note = 42.0.dp - val row_state = 150.0.dp - val row_lhead = 118.0.dp - val row_dhead = 450.0.dp - val row_phead = 235.0.dp - val row_gh = 42.0.dp - val row_endcta = 100.0.dp - val row_srow = 62.0.dp - val heroCopyLeft = 48.0.dp - val heroCopyBottom = 55.0.dp - val heroCopyWidth = 500.0.dp - val heroOverviewMax = 480.0.dp - val detailCopyTop = 100.0.dp - val detailCopyWidth = 530.0.dp - val heroSheetWidth = 210.0.dp - val heroSheetHeight = 315.0.dp - val buttonHeight = 38.0.dp - val buttonPaddingX = 19.0.dp - val buttonGap = 10.0.dp - val buttonIcon = 15.0.dp - val iconButton = 38.0.dp - val iconCaptionLine = 19.0.dp - val sheetWidth = 400.0.dp - val sheetRow = 50.0.dp - val settingsRowWidth = 650.0.dp - val settingsRowHeight = 52.0.dp - val stateWidth = 550.0.dp - val captionOffset = 19.0.dp - val badge = 24.0.dp - val badgeInset = 7.0.dp - val progressBar = 4.0.dp - val pickerAvatar = 110.0.dp - val pickerGap = 45.0.dp - val pickerStackGap = 35.0.dp - val gear = 28.0.dp - val navAvatar = 32.0.dp - val castCircle = 100.0.dp - val personPortrait = 120.0.dp - val thumbWidth = 48.0.dp - val thumbHeight = 72.0.dp - val row_chipsGrid = 68.0.dp - } - object Card { - val posterW = 128.0.dp - val posterH = 192.0.dp - val posterGap = 20.0.dp - val posterPitch = 148.0.dp - val posterCaptionBand = 48.0.dp - const val posterSlots = 6 - val tileW = 200.0.dp - val tileH = 112.5.dp - val tileGap = 20.0.dp - val tilePitch = 220.0.dp - val tileCaptionBand = 0.0.dp - const val tileSlots = 4 - val wideW = 272.0.dp - val wideH = 153.0.dp - val wideGap = 20.0.dp - val widePitch = 292.0.dp - val wideCaptionBand = 0.0.dp - const val wideSlots = 3 - val entryW = 128.0.dp - val entryH = 72.0.dp - val entryGap = 20.0.dp - val entryPitch = 148.0.dp - val entryCaptionBand = 0.0.dp - const val entrySlots = 6 - val personW = 100.0.dp - val personH = 155.0.dp - val personGap = 22.0.dp - val personPitch = 122.0.dp - val personCaptionBand = 0.0.dp - const val personSlots = 7 - } - object Focus { - const val scaleCard = 1.08f - const val scaleControl = 1.05f - const val scaleRow = 1.02f - val ringCardGap = 0.0.dp - val ringCardRing = 2.0.dp - val ringControlGap = 0.0.dp - val ringControlRing = 2.0.dp - val ringAvatarGap = 0.0.dp - val ringAvatarRing = 2.0.dp - val ringPickerGap = 0.0.dp - val ringPickerRing = 2.0.dp - const val fillInverse = "background color.fg, text color.onFocus" - } - object Motion { - const val fast = 140 - const val base = 160 - const val scroll = 180 - const val navBlobDampingRatio = 1.0f - const val navBlobStiffness = 650.0f - const val ratingDwell = 350 - const val toast = 3200 - const val reducedMotion = "all transitions instant" - } - object Image { - const val posterRequestWidth = 256 - const val posterRequestHeight = 384 - const val tileRequestWidth = 416 - const val tileRequestHeight = 234 - const val wideRequestWidth = 560 - const val wideRequestHeight = 315 - const val heroRequestWidth = 1280 - const val heroRequestHeight = 720 - const val castRequestWidth = 200 - const val castRequestHeight = 200 - const val memoryCacheMB = 64.0f - const val diskCacheMB = 128.0f - const val concurrentReadsCatalogue = 2 - const val concurrentReadsImage = 4 - } - object Behaviour { - const val rowLimit = 20.0f - const val letterRailMin = 200.0f - const val gridColumns = 6.0f - const val libraryChunk = 200.0f - const val laneLoadRows = 2.0f - const val nextPageCards = 10.0f - const val heroSlides = 5.0f - const val cacheRatingsHours = 6.0f - const val laneCacheMinutes = 30.0f - const val libraryRefreshMinutes = 10.0f - const val requestsPollSeconds = 30.0f - const val comingInVisibleRows = 5.0f - } - object Requests { - val sheetPaddingY = 40.0.dp - val sheetPaddingX = 32.0.dp - val sheetGap = 9.0.dp - val sheetTitleLineHeight = 28.0.dp - val sheetTitleBaseline = 22.0.dp - val titleLineHeight = 19.5.dp - val titleBaseline = 15.5.dp - val factsLineHeight = 15.6.dp - val factsBaseline = 12.0.dp - val stageLineHeight = 19.8.dp - val stageLabelLineHeight = 16.9.dp - val stageBaseline = 12.0.dp - val emptyMargin = 10.0.dp - val rowPaddingY = 7.0.dp - val rowPaddingX = 9.0.dp - val rowGap = 12.0.dp - val listGap = 3.0.dp - val stageMargin = 2.0.dp - val stageGlyph = 14.0.dp - val stageGap = 5.0.dp - val readinessGapX = 11.0.dp - val readinessGapY = 5.0.dp - val readinessMargin = 4.0.dp - val readinessGlyph = 13.0.dp - val chevron = 15.0.dp - const val chevronAlpha = 0.6f - val receiptPaddingY = 14.0.dp - val receiptPaddingX = 15.0.dp - val receiptGap = 5.0.dp - val receiptGlyph = 18.0.dp - val buttonMargin = 6.0.dp - val skeletonTitleWidth = 180.0.dp - val skeletonTitleHeight = 17.0.dp - val skeletonSubtitleWidth = 120.0.dp - val skeletonSubtitleHeight = 13.0.dp - val skeletonGap = 6.0.dp - val ratingsGap = 10.0.dp - val ratingsEntryGap = 4.0.dp - val shadowBlur = 40.0.dp - val shadowOffset = -15.0.dp - val shadowColor = ComposeColor(0x80000000) - const val ratingsWordmarkRatio = 1.8f - } - object Shell { - const val navLabelFade = 200 - val zero = 0.0.dp - val stageWidth = 960.0.dp - val stageHeight = 540.0.dp - val wordmarkWidth = 84.5.dp - val wordmarkTop = 36.8.dp - val navHomeWidth = 74.5.dp - val navDiscoverWidth = 99.0.dp - val navMoviesWidth = 85.5.dp - val navShowsWidth = 80.5.dp - val navAnimeWidth = 79.0.dp - val navItemTop = 32.5.dp - val pickerTitleTop = 136.15.dp - val pickerPeopleTop = 212.75.dp - val pickerNameGap = 13.0.dp - val pickerGearTop = 375.85.dp - val pickerGearCaptionTop = 36.0.dp - val pickerLetter = 50.0.dp - val pickerSkeletonNameWidth = 70.0.dp - val pickerSkeletonNameHeight = 20.0.dp - val pickerLoadingTitleTop = 123.7.dp - val pickerLoadingPeopleTop = 200.3.dp - val pickerLoadingButtonTop = 378.3.dp - val pickerErrorTitleTop = 148.8.dp - val pickerErrorPanelTop = 225.4.dp - val pickerErrorPanelWidth = 490.0.dp - val pickerErrorPanelHeight = 92.8.dp - val pickerErrorButtonTop = 353.2.dp - val settingsHeaderTop = 75.0.dp - val settingsHeaderAvatar = 26.0.dp - val settingsHeaderGap = 12.0.dp - val settingsRowTop = 5.0.dp - val settingsRowPadding = 15.0.dp - val settingsRowGap = 12.0.dp - val settingsIcon = 18.0.dp - val settingsPillHeight = 20.0.dp - val settingsPillStroke = 1.0.dp - const val settingsPillDashRatio = 3.0f - const val settingsPillDashGapRatio = 2.0f - val settingsPillBorder = ComposeColor(0x38FFFFFF) - val settingsPillFocusedBorder = ComposeColor(0x660B0B10) - val settingsPillPadding = 8.0.dp - val settingsPillGap = 6.0.dp - val settingsChevron = 15.0.dp - val settingsEndPadding = 310.0.dp - val panelPaddingX = 15.0.dp - val panelPaddingY = 14.0.dp - val panelGap = 5.0.dp - val panelHeadingGap = 7.0.dp - val panelIcon = 18.0.dp - val skeletonRadius = 5.0.dp - const val gearOpacity = 0.7f - const val navBandOpacity = 0.72f - const val navSolidStop = 0.84f - } - object Library { - val chipHeight = 34.0.dp - val chipPadding = 14.0.dp - val chipGap = 8.0.dp - val chipTop = 8.0.dp - val chipIcon = 13.0.dp - val captionWidth = 320.0.dp - const val captionSeparatorOpacity = 0.4f - val captionFactsGap = 9.0.dp - val badgeIcon = 14.0.dp - val plateTop = 7.0.dp - val plateSide = 8.0.dp - val plateBottom = 23.0.dp - val plateInlineDescent = 3.3.dp - val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - const val plateStep = 0.25f - const val plateGapEm = 0.64f - const val markGapEm = 0.29f - const val wordmarkRatio = 1.8f - const val plateOpacity = 0.9f - const val plateMidOpacity = 0.72f - const val plateMidStop = 0.52f - val railWidth = 26.0.dp - val railLetterHeight = 15.0.dp - val railFocusHeight = 22.0.dp - val railLetter = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val railFocusedLetter = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em) - val panelPaddingX = 20.0.dp - val panelPaddingY = 17.0.dp - val panelTextGap = 12.0.dp - val panelTitleGap = 4.0.dp - val panelIconGap = 7.0.dp - val panelTop = 10.0.dp - val borderWidth = 1.0.dp - val chipContentGap = 6.0.dp - const val chipLabelOpacity = 0.7f - val tailIcon = 22.0.dp - val tailPadding = 10.0.dp - val tailGap = 5.0.dp - val badgeRing = 1.5.dp - val seeAllGap = 4.0.dp - val tailDescription = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(500), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val headerSubtitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(400), lineHeight = 19.5.sp, letterSpacing = 0.0.em) - const val imageReads = 4.0f - const val unknownCriticOpacity = 0.55f - val fallbackPadding = 11.0.dp - val fallbackIcon = 22.0.dp - const val fallbackHueMultiplier = 31.0f - const val fallbackSaturationTop = 0.22f - const val fallbackLightnessTop = 0.22f - const val fallbackSaturationBottom = 0.26f - const val fallbackLightnessBottom = 0.1f - const val fallbackIconOpacity = 0.45f - } - object Lift { - val cardBlur = 25.0.dp - val cardY = 9.0.dp - const val cardOpacity = 0.65f - val buttonBlur = 20.0.dp - val buttonY = 6.0.dp - const val buttonOpacity = 0.5f - val heroBlur = 40.0.dp - val heroY = 15.0.dp - const val heroOpacity = 0.6f - val sheetBlur = 40.0.dp - val sheetX = -15.0.dp - const val sheetOpacity = 0.5f - } - object Title { - val lineGap = 11.0.dp - val lineLift = -2.0.dp - val overviewGap = 17.0.dp - val factsGap = 11.0.dp - val factsBottom = 7.0.dp - val factsHeight = 19.0.dp - val titleBottom = 8.0.dp - val iconGap = 7.0.dp - val iconSize = 16.0.dp - val captionTop = 8.0.dp - val progressWidth = 160.0.dp - val progressBottom = 15.0.dp - val progressGap = 9.0.dp - val progressLift = -7.0.dp - val castHeight = 231.0.dp - val castNameTop = 118.0.dp - val castNameSide = 6.0.dp - val personTop = 85.0.dp - val personGap = 24.0.dp - val personNameBottom = 5.0.dp - val personNoteBottom = 3.0.dp - val captionWidth = 320.0.dp - val sheetPaddingTop = 40.0.dp - val sheetPaddingSide = 32.0.dp - val sheetGap = 9.0.dp - val sheetSubLift = -4.0.dp - val sheetSubBottom = 7.0.dp - val sheetLabelTop = 4.0.dp - val sheetButtonTop = 6.0.dp - val sheetRowSide = 13.0.dp - val versionRowVertical = 10.0.dp - val trackHeight = 42.0.dp - val trackGap = 6.0.dp - val radioSize = 20.0.dp - val radioDot = 8.0.dp - val radioBorder = 1.5.dp - val infoGap = 7.0.dp - val infoLabelWidth = 100.0.dp - val infoRowGap = 3.0.dp - val infoColumnGap = 10.0.dp - val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val gridHeight = 262.0.dp - val minimumEndPadding = 40.0.dp - val ratingGap = 10.0.dp - val ratingMarkGap = 4.0.dp - val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em) - val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em) - const val fallbackHue = 260.0f - val skeletonKickerWidth = 210.0.dp - val skeletonKickerHeight = 17.0.dp - val skeletonTitleGap = 13.0.dp - val skeletonFactsGap = 12.0.dp - val skeletonOverviewGap = 15.0.dp - val skeletonTitleWidth = 380.0.dp - val skeletonTitleHeight = 46.0.dp - val skeletonFactsWidth = 280.0.dp - val skeletonOverviewWidth = 490.0.dp - val skeletonOverviewHeight = 55.0.dp - val skeletonPlayWidth = 110.0.dp - val dLine = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) - val iconCaption = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(400), lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) - val lineIcon = 14.0.dp - val chipHeight = 34.0.dp - val chipPadding = 14.0.dp - val chipGap = 8.0.dp - val chipTop = 8.0.dp - val chipBorder = 1.0.dp - val heroSheetRight = 90.0.dp - val heroSheetTop = 75.0.dp - val gridHeadingTop = 12.0.dp - val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) - val receiptSide = 15.0.dp - val receiptTop = 14.0.dp - val receiptGap = 5.0.dp - val receiptIcon = 18.0.dp - val receiptTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) - val toastMaxWidth = 380.0.dp - val toastSide = 14.0.dp - val toastTop = 11.0.dp - const val heroAmbientBackground = "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))" - const val dimScrollPx = 1400.0f - const val dimMaximum = 0.7f - val errorTop = 85.0.dp - val errorSide = 20.0.dp - val errorVertical = 17.0.dp - val errorHeadingBottom = 4.0.dp - val errorBodyBottom = 12.0.dp - val errorTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight.Bold, lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em) - val fallbackGlyph = 22.0.dp - val fallbackInset = 11.0.dp - val tileSheetInset = 9.0.dp - val tileSheetLeft = 11.0.dp - val tileSheetRadius = 5.0.dp - val tileAmbientBlur = 13.0.dp - val tileAmbientInset = 15.0.dp - val tileShadowRadius = 15.0.dp - val tileShadowY = 5.0.dp - const val tileShadowOpacity = 0.5f - const val tileAmbientBrightness = 0.5f - const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))" - const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" - val initialsColor = ComposeColor(0xD1FFFFFF) - val fallbackGlyphColor = ComposeColor(0x73FFFFFF) - val fallbackTitle = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.099999999999998.sp, letterSpacing = 0.0.em) - val tileOverlayTop = 27.0.dp - val tileOverlaySide = 11.0.dp - val tileOverlayBottom = 9.0.dp - const val cardOverlayOpacity = 0.92f - val cardProgressInset = 9.0.dp - val tileProgressBottom = 5.0.dp - val ambientInset = 40.0.dp - const val ambientScale = 1.1f - val posterShadowRadius = 40.0.dp - val posterShadowY = 15.0.dp - const val posterShadowOpacity = 0.6f - val sheetShadowRadius = 40.0.dp - val sheetShadowX = -15.0.dp - const val sheetShadowOpacity = 0.5f - } - object SavedIndicator { - val height = 26.0.dp - val side = 10.0.dp - val gap = 5.0.dp - val icon = 14.0.dp - val border = 1.0.dp - const val borderOpacity = 0.5f - } - object Home { - val kickerGap = 6.0.dp - val kickerBottom = 5.0.dp - val titleBottom = 8.0.dp - val factsHeight = 19.0.dp - val factsBottom = 7.0.dp - val factsGap = 11.0.dp - val overviewBottom = 15.0.dp - val cardPadding = 11.0.dp - val cardOverlayTop = 27.0.dp - val cardOverlayBottom = 9.0.dp - val sectionTop = 20.0.dp - val sectionAvatar = 28.0.dp - val sectionGap = 11.0.dp - val stateTextBottom = 12.0.dp - val stateTop = 10.0.dp - val statePaddingX = 20.0.dp - val statePaddingY = 17.0.dp - val stateGap = 4.0.dp - val stateIcon = 18.0.dp - val captionWidth = 320.0.dp - val plateTop = 7.0.dp - val plateSide = 8.0.dp - val plateFade = 23.0.dp - val ratingGap = 10.0.dp - val ratingMarkGap = 4.0.dp - val heroSkeletonWidth = 320.0.dp - val heroSkeletonHeight = 42.0.dp - val heroSkeletonFactsWidth = 230.0.dp - val heroSkeletonFactsHeight = 17.0.dp - val heroSkeletonButtonWidth = 130.0.dp - val heroSkeletonGap = 11.0.dp - val heroSkeletonButtonGap = 20.0.dp - val artGlyph = 22.0.dp - val cardSheetLeft = 11.0.dp - val cardSheetTop = 9.0.dp - val cardSheetInset = 18.0.dp - val heroSheetRight = 90.0.dp - val heroSheetTop = 75.0.dp - val cardBlur = 13.0.dp - val heroBlur = 30.0.dp - val progressInset = 9.0.dp - val progressBottom = 5.0.dp - val endTop = 20.0.dp - val frameBottom = 40.0.dp - val toastPadding = 14.0.dp - val toastTop = 75.0.dp - val toastWidth = 380.0.dp - val toastPaddingY = 11.0.dp - val toastGap = 7.0.dp - val toastShadowBlur = 30.0.dp - val toastShadowY = 10.0.dp - const val toastShadowOpacity = 0.5f - val cardWashInset = 15.0.dp - val heroWashInset = 40.0.dp - val noteTop = 7.0.dp - val noteGap = 8.0.dp - val hairline = 1.0.dp - val savedHeight = 26.0.dp - val savedPadding = 10.0.dp - val savedGap = 5.0.dp - val savedIcon = 14.0.dp - val savedRadius = 14.0.dp - val posterProgressBottom = 6.0.dp - } - object Discover { - val sheetPaddingX = 32.0.dp - val sheetPaddingY = 40.0.dp - val sheetGap = 9.0.dp - val sheetSubGap = 7.0.dp - val sheetRowPadding = 13.0.dp - val sheetAvatar = 30.0.dp - val sheetRadio = 20.0.dp - val sheetRadioDot = 8.0.dp - val sheetPersonGap = 12.0.dp - val sheetButtonTop = 6.0.dp - val chipTop = 8.0.dp - val chipHeight = 34.0.dp - val chipPadding = 14.0.dp - val chipGap = 8.0.dp - val heroTitleGap = 8.0.dp - val heroKickerGap = 5.0.dp - val heroMetaGap = 7.0.dp - val heroOverviewGap = 15.0.dp - val heroFactsGap = 11.0.dp - val heroFactsHeight = 19.0.dp - val heroSheetRight = 90.0.dp - val heroSheetTop = 70.0.dp - val heroSheetWidth = 180.0.dp - val heroSheetHeight = 270.0.dp - val dotSize = 7.0.dp - val dotActive = 22.0.dp - val dotGap = 7.0.dp - val dotMargin = 9.0.dp - val dotChevron = 20.0.dp - val stateTop = 10.0.dp - val statePaddingX = 20.0.dp - val statePaddingY = 17.0.dp - val stateTextGap = 12.0.dp - val noteTop = 7.0.dp - val headerTop = 75.0.dp - val headerGap = 12.0.dp - val entryPaddingX = 11.0.dp - val entryPaddingY = 10.0.dp - val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em) - val sheetRadioRing = 1.5.dp - val skeletonNameWidth = 130.0.dp - val skeletonNameHeight = 17.0.dp - val sheetSubTop = -4.0.dp - val sheetRatingTop = -5.0.dp - val sheetRatingBottom = 3.0.dp - val sheetLabelTop = 4.0.dp - val noteIcon = 15.0.dp - val noteGap = 8.0.dp - val frameBottomMin = 40.0.dp - val anchorFitTop = 350.0.dp - val anchorBottomClearance = 20.0.dp - const val reasonOpacity = 0.8f - val stageGap = 5.0.dp - const val entryBorderOpacity = 0.08f - val ambientInset = 40.0.dp - val ambientBlur = 30.0.dp - const val ambientScale = 1.1f - const val ambientSaturation = 1.2f - const val ambientBrightness = 0.55f - } -} diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png deleted file mode 100644 index ef0a98a6..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_fresh.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png deleted file mode 100644 index bb7becf5..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_imdb.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png deleted file mode 100644 index 678af063..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_rotten.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png deleted file mode 100644 index 988d6554..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_spilled.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png deleted file mode 100644 index 48a14150..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_tmdb.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png b/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png deleted file mode 100644 index 2a2bfa89..00000000 Binary files a/wolfskin-design/src/main/res/drawable-nodpi/coming_in_rating_upright.png and /dev/null differ diff --git a/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml b/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml deleted file mode 100644 index 6725d42f..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rating_imdb.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml b/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml deleted file mode 100644 index 31c0be7d..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rating_tmdb.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml deleted file mode 100644 index 5dbfb65e..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_fresh.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml b/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml deleted file mode 100644 index 1e3dc568..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rotten_tomatoes_rotten.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml b/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml deleted file mode 100644 index fea81f97..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rt_spilled.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml b/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml deleted file mode 100644 index e7166b39..00000000 --- a/wolfskin-design/src/main/res/drawable/ic_rt_upright.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_eyeoff.xml b/wolfskin-design/src/main/res/drawable/title_eyeoff.xml deleted file mode 100644 index 2e70993e..00000000 --- a/wolfskin-design/src/main/res/drawable/title_eyeoff.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/title_more.xml b/wolfskin-design/src/main/res/drawable/title_more.xml deleted file mode 100644 index 5a0c05ff..00000000 --- a/wolfskin-design/src/main/res/drawable/title_more.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_restart.xml b/wolfskin-design/src/main/res/drawable/title_restart.xml deleted file mode 100644 index 56b12c0e..00000000 --- a/wolfskin-design/src/main/res/drawable/title_restart.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_tracks.xml b/wolfskin-design/src/main/res/drawable/title_tracks.xml deleted file mode 100644 index 2bd8d4ea..00000000 --- a/wolfskin-design/src/main/res/drawable/title_tracks.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_trailer.xml b/wolfskin-design/src/main/res/drawable/title_trailer.xml deleted file mode 100644 index b81fba2c..00000000 --- a/wolfskin-design/src/main/res/drawable/title_trailer.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_version.xml b/wolfskin-design/src/main/res/drawable/title_version.xml deleted file mode 100644 index 2d6a9570..00000000 --- a/wolfskin-design/src/main/res/drawable/title_version.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml b/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml deleted file mode 100644 index ff7d9894..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_check.xml b/wolfskin-design/src/main/res/drawable/wolfskin_check.xml deleted file mode 100644 index 948e1e20..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_check.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml b/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml deleted file mode 100644 index be066ab9..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_clock.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_down.xml b/wolfskin-design/src/main/res/drawable/wolfskin_down.xml deleted file mode 100644 index e55cd1cb..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_down.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_download.xml b/wolfskin-design/src/main/res/drawable/wolfskin_download.xml deleted file mode 100644 index 4df16c9e..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_download.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml b/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml deleted file mode 100644 index 221d9b5e..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_eye.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_film.xml b/wolfskin-design/src/main/res/drawable/wolfskin_film.xml deleted file mode 100644 index 45648655..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_film.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml deleted file mode 100644 index b823a403..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml deleted file mode 100644 index e19e2857..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml b/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml deleted file mode 100644 index fe20e18d..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_hide.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml deleted file mode 100644 index 7bcf471d..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_left.xml b/wolfskin-design/src/main/res/drawable/wolfskin_left.xml deleted file mode 100644 index 62060544..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_left.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml deleted file mode 100644 index 96af58e1..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml b/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml deleted file mode 100644 index dc780c85..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_plus.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml deleted file mode 100644 index 7a1835b2..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml deleted file mode 100644 index fd08bb9f..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml deleted file mode 100644 index aa2679ce..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml deleted file mode 100644 index b902f2fd..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml deleted file mode 100644 index 52f1c851..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml b/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml deleted file mode 100644 index a600fb81..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_undo.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml deleted file mode 100644 index adbd06d3..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml b/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml deleted file mode 100644 index f43ded04..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_wifi.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt deleted file mode 100644 index c79d10db..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ComingInRatingsGateTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import java.util.Locale -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Test - -/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */ -@OptIn(ExperimentalCoroutinesApi::class) -class ComingInRatingsGateTest { - private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting, - ratings = listOf(ComingInRating("imdb", 6.0))) - private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2))) - - @Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest { - val state = ComingInState() - state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true)) - val abandoned = launch { state.awaitFocusedRatings() } - runCurrent() - advanceTimeBy(100) - assertFalse(state.ratingsVisible) - abandoned.cancel() - state.focus(second.id) - val next = launch { state.awaitFocusedRatings() } - runCurrent() - advanceTimeBy(349) - runCurrent() - assertFalse(state.ratingsVisible) - advanceTimeBy(1) - runCurrent() - assertTrue(state.ratingsVisible) - next.join() - state.focus(first.id) - assertFalse(state.ratingsVisible) - launch { state.awaitFocusedRatings() } - runCurrent() - advanceTimeBy(350) - runCurrent() - assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible) - state.focus(second.id) - assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible) - } - - @Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest { - val state = ComingInState() - val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true) - state.update(snapshot) - launch { state.awaitFocusedRatings() } - runCurrent(); advanceTimeBy(350); runCurrent() - assertTrue(state.ratingsVisible) - state.update(snapshot.copy(signedIn = false)) - assertFalse(state.ratingsVisible) - val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6))) - state.update(snapshot.copy(items = listOf(refreshed))) - assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible) - launch { state.awaitFocusedRatings() } - runCurrent(); advanceTimeBy(350); runCurrent() - assertTrue(state.ratingsVisible) - val rating = state.focused!!.ratings.single() - assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL"))) - assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH)) - assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL"))) - } - - @Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest { - val state = ComingInState() - val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared") - val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true) - state.update(online) - launch { state.awaitFocusedRatings() } - runCurrent(); advanceTimeBy(350); runCurrent() - state.update(online.copy(status = ComingInStatus.Unavailable)) - state.focus(second.id) - state.awaitFocusedRatings() - assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible) - state.focus(sharedCopy.id) - assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt deleted file mode 100644 index 68ee58f5..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult -import org.json.JSONArray -import org.json.JSONObject -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.Assert.assertTrue -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Captures execute the shipped Compose surface with offline fixture artwork and actual shell focus. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class DiscoverCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - private val fixture = discoverTestFixture() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) { - val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110) - val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") } - val snapshot = snapshotTransform(fixture.snapshot(person)) - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate), - onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service) - configure(state) - val painters = mutableMapOf() - rule.setContent { - WolfskinTheme { - CompositionLocalProvider(LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } - } - } - } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "discover").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - recordTypography(directory, name) - } - - private fun recordTypography(directory: File, name: String) { - val rows = JSONArray() - rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - val layouts = mutableListOf() - if (SemanticsActions.GetTextLayoutResult in node.config) node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) - layouts.forEach { layout -> - val text = layout.layoutInput.text.text - val lines = JSONArray() - repeat(layout.lineCount) { index -> lines.put(text.substring(layout.getLineStart(index), layout.getLineEnd(index, visibleEnd = true))) } - rows.put(JSONObject().put("text", text).put("lines", layout.lineCount).put("lineTexts", lines) - .put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight) - .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString()) - .put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale) - .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height)) - } - } - File(directory, "$name-type-metrics.json").writeText(rows.toString(2)) - } - - @Test fun st029_discover() = capture("st-029") - @Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) } - @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } - @Test fun st031_hidden() = capture("st-031") { hidden(it) } - @Test fun st032_restore() { - capture("st-032") { state -> hidden(state); state.overlay = DiscoverOverlayState.Restore(state.snapshot.picks[7]) } - val title = rule.onAllNodesWithText(fixture.snapshot("justin").picks[7].title, useUnmergedTree = true) - .fetchSemanticsNodes().maxBy { it.boundsInRoot.left }.boundsInRoot - val sub = rule.onNodeWithText("Hidden from Justin’s picks.", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val gap = with(rule.density) { (WolfskinTokens.Discover.sheetGap + WolfskinTokens.Discover.sheetSubTop).toPx() } - assertTrue("Restore subtitle must retain the negative top margin", kotlin.math.abs(sub.top - title.bottom - gap) <= 1f) - } - @Test fun st033_failedBadge() = capture("st-033") { state -> - val lane = state.lanes.first { it.id == "pk-movie" } - state.requests[lane.titles[1].key] = listOf(DiscoverRequestResult(RequestPhase.Receipt, "Justin", "Failed", requester = "justin")) - state.focus(state.lanes.indexOf(lane) + 2, 1) - } - @Test fun st034_retryTail() = capture("st-034", snapshotTransform = { snapshot -> - snapshot.copy(lanes = snapshot.lanes.map { if (it.id == "trending") it.copy(tail = DiscoverTail.Retry) else it }) - }) { state -> val lane = state.lanes.first { it.id == "trending" }; state.focus(state.lanes.indexOf(lane) + 2, lane.titles.size) } - @Test fun st035_duplicateTail() = capture("st-035") { state -> - val titles = state.snapshot.lanes.first { it.id == "trending" }.fullTitles.take(36) - state.openCollection(DiscoverLane("trending", "Trending", titles, tail = DiscoverTail.ShowMore)) - state.collection!!.index = state.collection!!.tailIndex - } - @Test fun st036_needsSignIn() { - capture("st-036", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn) }) - val button = rule.onNodeWithText("Try again").fetchSemanticsNode().boundsInRoot - val minimum = with(rule.density) { WolfskinTokens.Layout.buttonHeight.toPx() } - assertTrue("The sign-in button must not shrink to fit the scroll row", button.height >= minimum) - } - @Test fun st037_series() { - capture("st-037", person = "mindel") { it.filter = "tv" } - val title = rule.onNodeWithText("56 Days", useUnmergedTree = true).fetchSemanticsNode() - val layouts = mutableListOf() - title.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) - val layout = layouts.single() - val cssHeight = with(rule.density) { DiscoverType.hero.lineHeight.toPx() } * layout.lineCount - assertTrue("Natural font height must not inflate the hero CSS line box", kotlin.math.abs(title.boundsInRoot.height - cssHeight) <= 1f) - } - @Test fun st038_noSeeds() = capture("st-038", snapshotTransform = { it.copy(status = DiscoverStatus.NoSeeds) }) - @Test fun st039_trendingCollection() = capture("st-039") { state -> state.openCollection(state.lanes.first { it.id == "trending" }) } - @Test fun st040_service() = capture("st-040") { state -> state.openService(state.snapshot.services.first()) } - @Test fun st041_unreachable() = capture("st-041", snapshotTransform = { DiscoverSnapshot(status = DiscoverStatus.Unavailable) }) - @Test fun st058_hiddenReceipt() = capture("st-058") { state -> state.openTitle(state.snapshot.picks.first()); state.titleButton = 2; state.hide(state.title!!) } - @Test fun st059_hiddenTitle() = capture("st-059") { state -> state.hide(state.snapshot.picks.first()); state.overlay = null; state.openTitle(state.snapshot.picks.first()); state.titleButton = 2 } - @Test fun st071_requestSelf() = capture("st-071") { request(it) } - @Test fun st072_requestOther() = capture("st-072") { request(it, other = true) } - @Test fun st073_addHeld() = capture("st-073") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } - @Test fun st074_receipt() = capture("st-074") { request(it, other = true, phase = RequestPhase.Receipt) } - @Test fun st075_notConfirmed() = capture("st-075") { request(it, phase = RequestPhase.NotConfirmed) } - @Test fun st076_pending() = capture("st-076") { request(it, phase = RequestPhase.Pending) } - @Test fun st077_refused() = capture("st-077", person = "mindel") { request(it, other = true, phase = RequestPhase.Refused) } - @Test fun st078_recipientsLoading() = capture("st-078") { request(it, other = true, phase = RequestPhase.LoadingRecipients) } - @Test fun st079_recipientsUnavailable() = capture("st-079") { request(it, other = true, phase = RequestPhase.RecipientsUnavailable) } - @Test fun st080_seasonsUnknown() = capture("st-080") { state -> request(state, state.snapshot.picks.first { it.type == "tv" }) } - @Test fun st087_sharedReceipt() = capture("st-087", person = "shared") { request(it, phase = RequestPhase.Receipt) } - @Test fun st105_pickPlate() = capture("st-105") { state -> val lane = state.lanes.first { it.id == "pk-movie" }; state.focus(state.lanes.indexOf(lane) + 2, 1) } - @Test fun st110_heldRatings() = capture("st-110") { state -> request(state, fixture.find("The Girl Who Leapt Through Time"), other = true) } - - private fun hidden(state: DiscoverState) { - state.hide(state.snapshot.picks[7]); state.hide(state.snapshot.picks[20]); state.overlay = null - val lane = state.lanes.first { it.hidden } - state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == state.snapshot.picks[7].key }) - } - - private fun request(state: DiscoverState, title: DiscoverTitle = state.snapshot.picks.first(), other: Boolean = false, phase: RequestPhase = RequestPhase.Choose) { - state.openTitle(title); state.openRequest(other, phase = phase) - if (phase == RequestPhase.Receipt) { - val sheet = (state.overlay as DiscoverOverlayState.Request).state - val receipt = DiscoverRequestResult(phase, state.shell.person.name, requester = sheet.selectedPerson.key) - sheet.result = receipt; state.requests[title.key] = listOf(receipt) - } - } -} - -internal fun discoverTestFixture(): DiscoverFixture { - val json = DiscoverCaptureTest::class.java.getResourceAsStream("/wolfskin-discover.json")!!.bufferedReader().use { it.readText() } - val assets = File("../app/src/debug/assets/wolfskin-discover").canonicalFile - return DiscoverFixture(json) { File(assets, it).absolutePath } -} - -/** In-process effects are inspectable without exposing any live client or endpoint. */ -internal class RecordingDiscoverOperations(private val snapshot: DiscoverSnapshot, private val actor: String) : DiscoverOperations { - val sent = mutableListOf() - var checked = 0 - var next = RequestPhase.Receipt - var mutationSucceeds = true - override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { sent += intent; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) } - override suspend fun check(intent: DiscoverRequest): DiscoverRequestResult { checked++; return DiscoverRequestResult(next, actor.replaceFirstChar { it.uppercase() }) } - override suspend fun hide(profile: String, title: DiscoverTitle) = mutationSucceeds - override suspend fun restore(profile: String, title: DiscoverTitle) = mutationSucceeds - override suspend fun refresh() = snapshot -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt deleted file mode 100644 index e5bd3c3a..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ /dev/null @@ -1,274 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.KeyEvent as AndroidKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Acceptance data drives real Android events through the shell's production focus adapter. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class DiscoverKeysTest { - @get:Rule val rule = createComposeRule() - private val fixture = discoverTestFixture() - private val shell = ShellState(fixturePeople(), "justin") - private val snapshot = fixture.snapshot("justin") - private val operations = RecordingDiscoverOperations(snapshot, "justin") - private val state = DiscoverState(shell, snapshot, operations, CoroutineScope(Dispatchers.Main.immediate), shell::enterDestination, - { shell.enterDestination("discover") }, fixture::service) - - private fun launch() { - rule.setContent { - WolfskinTheme { - ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> - if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) - } - } - } - } - - private fun press(first: Key, second: Key? = null) = listOfNotNull(first, second).forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } - - private fun replay(goal: String) { - val paths = JSONObject(javaClass.getResourceAsStream("/discover-keys.json")!!.bufferedReader().use { it.readText() }).getJSONArray("pressPaths") - val path = (0 until paths.length()).map { paths.getJSONObject(it) }.single { it.getString("goal") == goal } - val keys = path.getJSONArray("keys") - assertEquals(path.getInt("presses"), keys.length()) - launch() - repeat(keys.length()) { index -> press(when (keys.getString(index)) { - "OK" -> Key.DirectionCenter; "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown; "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight; else -> Key.Back - }) } - rule.waitForIdle() - } - - @Test fun openDiscoverInFourPresses() { - replay("Open Discover") - assertEquals("discover", shell.route) - assertFalse(shell.navFocused) - assertEquals("The Amazing Spider-Man", state.hero?.title) - } - - @Test fun requestForYourselfInSevenPresses() { - replay("Request a pick for yourself") - val sheet = (state.overlay as DiscoverOverlayState.Request).state - assertEquals(RequestPhase.Receipt, sheet.phase) - assertEquals("justin", operations.sent.single().requester) - assertEquals("Justin", sheet.result?.actor) - assertEquals("Waiting", state.requests.getValue(requireNotNull(state.title).key).single().stage) - } - - @Test fun requestForMindelInNinePresses() { - replay("Request a pick for Mindel") - val sheet = (state.overlay as DiscoverOverlayState.Request).state - assertEquals(RequestPhase.Receipt, sheet.phase) - assertEquals("mindel", operations.sent.single().requester) - assertEquals("Justin", sheet.result?.actor) - } - - @Test fun notInterestedInEightPressesAndUndoRestoresEveryIndex() { - val original = state.lanes.map { lane -> lane.id to lane.titles.map { it.key } } - replay("Round 3 · Not interested on a pick (Discover hero)") - val hidden = state.overlay as DiscoverOverlayState.Hidden - assertTrue(state.isHidden(hidden.title)) - assertTrue(state.heroes.none { it.key == hidden.title.key }) - assertTrue(state.lanes.filterNot { it.hidden }.none { lane -> lane.titles.any { it.key == hidden.title.key } }) - press(Key.DirectionCenter) - assertNull(state.overlay) - assertEquals("The Amazing Spider-Man", state.hero?.title) - assertEquals(original, state.lanes.map { lane -> lane.id to lane.titles.map { it.key } }) - assertNull("Undo restores silently; only Show again announces the return", state.toast) - } - - @Test fun showAgainRestoresTheHiddenPickAndAnnouncesItsReturn() { - replay("Round 3 · Not interested on a pick (Discover hero)") - val title = (state.overlay as DiscoverOverlayState.Hidden).title - press(Key.DirectionRight, Key.DirectionCenter) - rule.runOnIdle { - val lane = state.lanes.first { it.hidden } - state.focus(state.lanes.indexOf(lane) + 2, lane.titles.indexOfFirst { it.key == title.key }) - } - press(Key.DirectionCenter) - assertTrue(state.overlay is DiscoverOverlayState.Restore) - press(Key.DirectionCenter) - assertFalse(state.isHidden(title)) - assertEquals("Back in your picks.", state.toast) - } - - @Test fun heldOkOpensSheetButNeverSubmits() { - replay("Open Discover") - press(Key.DirectionCenter) - rule.runOnIdle { - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - repeat(8) { index -> shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, index + 1))) } - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - } - assertEquals(RequestPhase.Choose, (state.overlay as DiscoverOverlayState.Request).state.phase) - assertTrue(operations.sent.isEmpty()) - } - - @Test fun uncertainOutcomeChecksWithoutResending() { - operations.next = RequestPhase.NotConfirmed - replay("Request a pick for yourself") - assertEquals(1, operations.sent.size) - assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) - operations.next = RequestPhase.Receipt - press(Key.DirectionCenter) - assertEquals(1, operations.sent.size) - assertEquals(1, operations.checked) - assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) - } - - @Test fun closingAnUncertainReceiptNeverTurnsReopeningIntoAnotherPost() { - operations.next = RequestPhase.NotConfirmed - replay("Request a pick for yourself") - press(Key.Back, Key.DirectionCenter) - assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) - operations.next = RequestPhase.Receipt - press(Key.DirectionCenter) - assertEquals(1, operations.sent.size) - assertEquals(1, operations.checked) - assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) - } - - @Test fun requestingForAnotherPersonKeepsYourOwnRequestedAction() { - replay("Request a pick for yourself") - press(Key.DirectionCenter) - press(Key.DirectionRight, Key.DirectionCenter) - press(Key.DirectionCenter, Key.DirectionCenter) - assertEquals(setOf("justin", "mindel"), state.requests.getValue(state.title!!.key).map { it.requester }.toSet()) - press(Key.DirectionCenter) - press(Key.DirectionLeft, Key.DirectionCenter) - assertTrue(shell.requestsVisible) - assertEquals(2, operations.sent.size) - } - - @Test fun choosingAnUncertainRecipientAgainNeverResubmits() { - operations.next = RequestPhase.NotConfirmed - replay("Open Discover") - press(Key.DirectionCenter) - press(Key.DirectionRight, Key.DirectionCenter) - press(Key.DirectionDown, Key.DirectionCenter) - press(Key.DirectionCenter) - assertEquals("shared", operations.sent.single().requester) - press(Key.Back, Key.DirectionCenter) - press(Key.DirectionDown, Key.DirectionCenter) - press(Key.DirectionCenter) - assertEquals(1, operations.sent.size) - assertEquals(RequestPhase.NotConfirmed, (state.overlay as DiscoverOverlayState.Request).state.phase) - operations.next = RequestPhase.Receipt - press(Key.DirectionCenter) - assertEquals(1, operations.sent.size) - assertEquals(1, operations.checked) - assertEquals(RequestPhase.Receipt, (state.overlay as DiscoverOverlayState.Request).state.phase) - } - - @Test fun unknownSeriesSeasonsNeverSubmitAndBackRestoresOpener() { - replay("Open Discover") - rule.runOnIdle { state.openTitle(snapshot.picks.first { it.type == "tv" }); state.openRequest(false) } - press(Key.DirectionCenter) - assertTrue(operations.sent.isEmpty()) - assertNull(state.overlay) - assertNotNull(state.title) - assertFalse(shell.navFocused) - } - - @Test fun cardOkWaitsForReleaseAndFocusMoveCancelsActivation() { - replay("Open Discover") - press(Key.DirectionDown, Key.DirectionDown) - rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) } - assertNull(state.title) - press(Key.DirectionRight) - rule.runOnIdle { shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) } - assertNull(state.title) - press(Key.DirectionCenter) - assertNotNull(state.title) - } - @Test fun downClampsToOccupiedCardsAndThenTheTail() { - shell.pickerVisible = false - val items = snapshot.picks.take(8) - state.openCollection(DiscoverLane("short", "Picks", items, tail = DiscoverTail.ShowMore)) - launch() - repeat(5) { press(Key.DirectionRight) } - assertEquals(5, state.collection!!.index % state.collection!!.columns) - press(Key.DirectionDown) - assertEquals(1, state.collection!!.index / state.collection!!.columns) - assertEquals(1, state.collection!!.index % state.collection!!.columns) - assertEquals(items.last().key, state.collection!!.items[state.collection!!.index].id) - press(Key.DirectionDown, Key.DirectionRight) - assertEquals(2, state.collection!!.index / state.collection!!.columns) - assertEquals(0, state.collection!!.index % state.collection!!.columns) - assertEquals(GridTail.ShowMore, state.collection!!.tail) - } - - @Test fun collectionRetryReadsNewTitlesAndOpensTheFirstAppendedTitle() { - shell.pickerVisible = false - val lane = state.lanes.first { it.id == "trending" } - state.openCollection(lane.copy(titles = lane.fullTitles.take(6), fullTitles = lane.fullTitles.take(6), tail = DiscoverTail.Retry)) - val grid = state.collection!! - grid.index = grid.tailIndex - launch() - press(Key.DirectionCenter) - assertEquals(GridTail.None, grid.tail) - assertEquals(lane.fullTitles.map { it.key }, grid.items.map { it.id }) - assertEquals(lane.fullTitles[6].key, grid.focusedTitle?.id) - press(Key.DirectionCenter) - assertEquals(lane.fullTitles[6], state.title) - } - - @Test fun collectionDismissalAndUndoKeepTheOriginalOrderAndFocus() { - shell.pickerVisible = false - state.openCollection(state.lanes.first { it.id == "pk-movie" }) - val grid = state.collection!! - val original = grid.items.map { it.id } - launch() - press(Key.DirectionCenter) - val opener = state.title!! - press(Key.DirectionRight, Key.DirectionRight) - press(Key.DirectionCenter) - assertFalse(grid.items.any { it.id == opener.key }) - press(Key.DirectionCenter) - assertEquals(original, grid.items.map { it.id }) - assertEquals(opener.key, grid.focusedTitle?.id) - } - - @Test fun collectionBackRestoresTheExactOrigin() { - shell.pickerVisible = false; shell.enterDestination("discover") - state.focus(3, 2) - val origin = shell.view - state.openCollection(state.lanes.first { it.id == "pk-movie" }) - launch() - press(Key.DirectionDown, Key.Back) - assertNull(state.collection) - assertSame(origin, shell.view) - assertEquals(3, shell.view.row) - assertEquals(2, shell.view.cards[state.rowKey]) - } - - @Test - @Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi") - fun dutchPlateUsesLocaleAndStaysOnOneLine() { - shell.pickerVisible = false - val title = snapshot.picks.first().copy(ratings = listOf(DiscoverRating("tmdb", 7.8))) - state.openCollection(DiscoverLane("locale", "Picks", listOf(title))) - launch() - rule.mainClock.advanceTimeBy(1000) - rule.onNodeWithText("7,8").assertExists() - assertEquals("7.8", formatDiscoverRating(title.ratings.single(), java.util.Locale.ENGLISH)) - assertEquals("91%", formatDiscoverRating(DiscoverRating("rtCritics", 91.7), java.util.Locale.forLanguageTag("nl-NL"))) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt deleted file mode 100644 index ed62d6f4..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverLaneProjectionTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** Feed projection must not lose titles when a contribution row is too short to display. */ -class DiscoverLaneProjectionTest { - @Test fun discardedSeedGroupRemainsAvailableInCatalogue() { - val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") } - val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))), - lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready) - val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap()) - assertEquals(listOf("pk-movie", "trending"), rows.map { it.id }) - assertEquals(titles.take(3), rows.first().titles) - assertEquals(titles.drop(3), rows.last().titles) - } - - @Test fun catalogueDeduplicationKeepsMediaTypesDistinctAndHiddenPicksLast() { - val movie = DiscoverTitle(1, "movie", "Film") - val series = DiscoverTitle(1, "tv", "Series") - val hidden = DiscoverTitle(2, "movie", "Hidden") - val later = DiscoverTitle(3, "movie", "Later") - val snapshot = DiscoverSnapshot(hidden = listOf(hidden), status = DiscoverStatus.Ready, lanes = listOf( - DiscoverLane("first", "First", listOf(movie, series, hidden)), - DiscoverLane("second", "Second", listOf(movie, series, later)))) - val rows = projectDiscoverLanes(snapshot, emptyList(), "all", mapOf(hidden.key to hidden)) - assertEquals(listOf(listOf(movie, series), listOf(later), listOf(hidden)), rows.map { it.titles }) - assertEquals("hidden", rows.last().id) - val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden)) - assertEquals(listOf(listOf(series)), filtered.map { it.titles }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt deleted file mode 100644 index 9a2b6ae5..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertTrue -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Opaque content must not cover either half of the shared accent focus stroke. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class FocusRingTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - val focused = mutableStateOf(true) - val radius = mutableStateOf(WolfskinTokens.Radius.button) - rule.setContent { - Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) { - Box(Modifier.offset(40.dp, 40.dp).size(60.dp) - .focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing) - .background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value))) - } - } - val file = File.createTempFile("focus-ring-", ".png") - try { - for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) { - rule.runOnIdle { radius.value = corner } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent, - 79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent, - 81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) { - assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140)) - assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x)) - } - bitmap.recycle() - } - rule.runOnIdle { focused.value = false } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140)) - assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140)) - bitmap.recycle() - } finally { file.delete() } - } - private fun assertPixel(label: String, expected: Int, actual: Int) { - // Circular edge coverage can differ by a few channel values at pixel centres. - for (shift in listOf(0, 8, 16, 24)) { - val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255)) - assertTrue("$label: channel $shift differs by $difference", difference <= 8) - } - } -} \ No newline at end of file diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt deleted file mode 100644 index 970ea46e..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeArtTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** An unreadable local image exercises Coil's real error path without a network provider. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class HomeArtTest { - @get:Rule val rule = createComposeRule() - - @Test fun imageFailureShowsTheTitleFallbackInsteadOfAnEmptyCard() { - val title = HomeTitle("missing-image", "A missing film", "movie", poster = "file:///android_asset/wolfskin-home/not-present.jpg") - rule.setContent { WolfskinTheme { TitleArtwork(title.title, title.type, title.poster, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)) } } - rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithText(title.title).assertIsDisplayed() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt deleted file mode 100644 index 323b52ff..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult -import org.json.JSONArray -import org.json.JSONObject -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import java.util.Locale -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Every frozen Home acceptance state renders the actual native Compose surface at density 2. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class HomeCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - private fun capture(name: String, person: String = "justin", mode: String = "ready", row: String = "hero", column: Int = 0, nav: Int? = null) { - val previous = Locale.getDefault() - Locale.setDefault(Locale.US) - try { - val snapshot = HomeFixture(rule.activity).snapshot(person, mode) - val shell = ShellState(fixturePeople(), person).apply { - pickerVisible = false - view.row = snapshot.rows.indexOfFirst { it.id == row }.coerceAtLeast(0) - view.cards[row] = column - if (nav != null) { navFocused = true; navIndex = nav } - } - preload(snapshot, shell.view.row) - rule.setContent { WolfskinTheme { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> HomeScreen(shell, snapshot, modifier) {} } - } } - rule.mainClock.advanceTimeBy(1200) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - recordTextLayouts(File(directory, "$name-layout.json")) - } finally { Locale.setDefault(previous) } - } - - private fun recordTextLayouts(output: File) { - val nodes = JSONArray() - rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - val results = mutableListOf() - node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) - results.forEach { layout -> - val lines = JSONArray() - repeat(layout.lineCount) { line -> - lines.put(JSONObject().put("top", layout.getLineTop(line)).put("bottom", layout.getLineBottom(line)).put("baseline", layout.getLineBaseline(line))) - } - nodes.put(JSONObject().put("text", layout.layoutInput.text.text) - .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top) - .put("width", layout.size.width).put("height", layout.size.height) - .put("lineHeight", layout.layoutInput.style.lineHeight.toString()) - .put("lineHeightStyle", layout.layoutInput.style.lineHeightStyle.toString()).put("lines", lines)) - } - } - output.writeText(nodes.toString(2)) - } - - private fun preload(snapshot: HomeSnapshot, focused: Int) = runBlocking { - val rows = if (focused == 0) snapshot.rows.take(2) else snapshot.rows.drop(focused).take(3) - for (row in rows) for (title in row.cards.take(8)) { - val hero = row.kind == HomeRowKind.Hero - val landscape = hero || row.cardKind != HomeCardKind.Poster - val width = if (hero) 1280 else (row.cardWidth.value * 2).toInt() - val height = if (hero) 720 else (row.cardHeight.value * 2).toInt() - val source = if (landscape) title.backdrop ?: title.poster else title.poster - if (source != null) WolfskinImages.loader(rule.activity).execute(WolfskinImages.request(rule.activity, source, width, height, !landscape)) - } - } - - @Test fun st005_loading() = capture("st-005", mode = "loading", nav = 0) - @Test fun st006_justinNext() = capture("st-006") - @Test fun st007_mindelResume() = capture("st-007", person = "mindel") - @Test fun st008_sharedResume() = capture("st-008", person = "shared") - @Test fun st009_deepTrending() = capture("st-009", row = "trending", column = 2) - @Test fun st010_heroNav() = capture("st-010", nav = 0) - @Test fun st011_deepBackNav() = capture("st-011", row = "lib-shows", column = 2, nav = 0) - @Test fun st012_explore() = capture("st-012", row = "explore") - @Test fun st013_unsigned() = capture("st-013", mode = "unsigned", row = "signin") - @Test fun st014_discoverNav() = capture("st-014", nav = 1) - @Test fun st015_offlineSaved() = capture("st-015", mode = "offline") - @Test fun st016_conductarrDown() = capture("st-016", mode = "cond-down", row = "cond-down") - @Test fun st017_conductarrSaved() = capture("st-017", mode = "cond-cached") - @Test fun st018_jellyfinDown() = capture("st-018", mode = "jf-down") - @Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending") - @Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies") - @Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4) - @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu") - @Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1) - @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) - @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) - @Test fun st103_heroRatings() = capture("st-103") - @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") - @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) - @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt deleted file mode 100644 index 01c953b8..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.activity.ComponentActivity -import androidx.compose.runtime.* -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.json.JSONArray -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Approved replay data drives the same Android event handling used by the Home surface. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class HomeKeysTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun approvedHomePathsMatchFocusAndCounts() { - val fixture = HomeFixture(rule.activity) - var shell by mutableStateOf(ShellState(fixturePeople(), "justin")) - var action: HomeAction? = null - rule.setContent { WolfskinTheme { - val snapshot = remember(shell, shell.person) { fixture.snapshot(shell.person.key) } - ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } - } } - val paths = javaClass.getResourceAsStream("/wolfskin-home-presses.json")!!.bufferedReader().use { JSONArray(it.readText()) } - for (index in 0 until paths.length()) { - val path = paths.getJSONObject(index) - val keys = path.getJSONArray("keys") - rule.runOnIdle { shell = ShellState(fixturePeople(), "justin"); action = null } - for (key in 0 until keys.length()) press(keys.getString(key)) - assertEquals(path.getString("goal"), path.getInt("presses"), keys.length()) - when (index) { - 0 -> { assertEquals("mindel", shell.person.key); assertTrue(action is HomeAction.Play); assertTrue((action as HomeAction.Play).resume) } - 1 -> { assertTrue(action is HomeAction.Play); assertFalse((action as HomeAction.Play).resume); assertEquals("Yu Yu Hakusho", (action as HomeAction.Play).title.seriesTitle) } - 2 -> assertEquals("trending", fixture.snapshot("justin").rows[shell.view.row].id) - 3 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("trending", row.id); assertEquals(row.cards.size, shell.view.cards[row.id]); assertTrue(row.hasSeeAll) } - 4 -> { val row = fixture.snapshot("justin").rows[shell.view.row]; assertEquals("lib-shows", row.id); assertEquals(2, shell.view.cards[row.id]); assertFalse(shell.navFocused) } - 5 -> assertEquals("discover", shell.route) - } - } - } - - @Test fun cardsActivateOnlyOnReleaseAndMovementCancelsArming() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val snapshot = HomeFixture(rule.activity).snapshot("justin") - var action: HomeAction? = null - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } - press("DOWN") - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter) } - assertNull(action) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyUp(Key.DirectionCenter) } - assertTrue(action is HomeAction.Details) - rule.runOnIdle { action = null } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { keyDown(Key.DirectionCenter); pressKey(Key.DirectionRight); keyUp(Key.DirectionCenter) } - assertNull(action) - } - - @Test fun loadingHasNavFocus() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val snapshot = HomeFixture(rule.activity).snapshot("justin", "loading") - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } } - rule.waitForIdle() - assertTrue(shell.navFocused) - assertEquals(0, shell.navIndex) - } - - @Test fun returningHomeRestoresTheExactViewportAndArrowReentersVisibleContent() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val snapshot = HomeFixture(rule.activity).snapshot("justin") - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> - if (shell.route == "home") HomeScreen(shell, snapshot, modifier) {} else ShellPlaceholder(shell.route, modifier) - } } } - rule.onNodeWithTag("wolfskin-home").performScrollToIndex(3) - val view = shell.view - val savedIndex = view.listState.firstVisibleItemIndex - val savedOffset = view.listState.firstVisibleItemScrollOffset - assertEquals(3, savedIndex) - rule.runOnIdle { shell.enterDestination("movies") } - rule.runOnIdle { shell.enterDestination("home") } - rule.waitForIdle() - assertSame(view, shell.view) - assertEquals(savedIndex, view.listState.firstVisibleItemIndex) - assertEquals(savedOffset, view.listState.firstVisibleItemScrollOffset) - press("RIGHT") - assertEquals("lib-movies", snapshot.rows[view.row].id) - assertEquals(0, view.cards["lib-movies"]) - } - - @Test fun pointerHoverMovesFocusWithoutActivatingTheCard() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val snapshot = HomeFixture(rule.activity).snapshot("justin") - var action: HomeAction? = null - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } - press("DOWN") - rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) } - rule.waitForIdle() - assertEquals("nu", snapshot.rows[shell.view.row].id) - assertEquals(2, shell.view.cards["nu"]) - assertNull(action) - } - - @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } - val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards - val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) - val short = long.copy(id = "short", cards = cards.take(2), total = 2) - val tail = short.copy(id = "tail", total = 20) - shell.view.cards[long.id] = cards.lastIndex - var action: HomeAction? = null - rule.setContent { WolfskinTheme { - ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } - } } - press("DOWN") - assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id) - assertEquals(1, shell.view.cards[short.id]) - rule.onNodeWithTag("home-card:short:1").assertIsDisplayed() - press("RIGHT") - assertEquals(1, shell.view.cards[short.id]) - press("DOWN") - press("RIGHT") - press("DOWN") - assertEquals(2, shell.view.row) - assertEquals(2, shell.view.cards[tail.id]) - rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed() - press("OK") - assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) - } - - @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } - val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards - val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) - val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) - shell.view.cards[posters.id] = 3 - rule.setContent { WolfskinTheme { - ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } - } } - press("DOWN") - assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) - repeat(4) { press("RIGHT") } - val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left - press("LEFT") - assertEquals("Moving back within the visible middle must not shift the track", left, - rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f) - press("UP") - assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id]) - } - - @Test fun asynchronousFreshHomePlaysNextInTwoPresses() { - val fixture = HomeFixture(rule.activity) - val shell = ShellState(fixturePeople(), "justin") - val delivery = kotlinx.coroutines.CompletableDeferred() - var action: HomeAction? = null - rule.setContent { - val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() } - WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } - } - press("OK") - rule.waitForIdle() - assertTrue(shell.navFocused) - rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) } - rule.waitForIdle() - assertFalse(shell.navFocused) - assertEquals(0, shell.view.cards["hero"]) - press("OK") - val play = action as HomeAction.Play - assertFalse(play.resume) - assertEquals("Yu Yu Hakusho", play.title.seriesTitle) - } - - @Test fun asynchronousHomeDoesNotStealFocusAfterNavInput() { - val fixture = HomeFixture(rule.activity) - val shell = ShellState(fixturePeople(), "justin") - val delivery = kotlinx.coroutines.CompletableDeferred() - rule.setContent { - val snapshot by produceState(fixture.snapshot("justin", "loading")) { value = delivery.await() } - WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } - } - press("OK") - press("RIGHT") - rule.runOnIdle { delivery.complete(fixture.snapshot("justin")) } - rule.waitForIdle() - assertTrue(shell.navFocused) - assertEquals(1, shell.navIndex) - } - - @Test fun heldOkRepeatEventsDoNotReplayButtonsOrActivateCardsBeforeRelease() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val snapshot = HomeFixture(rule.activity).snapshot("justin") - val actions = mutableListOf() - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { actions += it } } } } - val input = rule.onNodeWithTag("wolfskin-shell") - fun repeatOk() = rule.runOnIdle { - rule.activity.dispatchKeyEvent(android.view.KeyEvent(0L, 0L, android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_CENTER, 3)) - } - input.performKeyInput { keyDown(Key.DirectionCenter) } - repeatOk() - assertEquals(1, actions.size) - assertTrue(actions.single() is HomeAction.Play) - input.performKeyInput { keyUp(Key.DirectionCenter) } - press("DOWN") - input.performKeyInput { keyDown(Key.DirectionCenter) } - repeatOk() - assertEquals(1, actions.size) - input.performKeyInput { keyUp(Key.DirectionCenter) } - assertEquals(2, actions.size) - assertTrue(actions.last() is HomeAction.Details) - } - - private fun press(name: String) { - val key = when (name) { - "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown - "LEFT" -> Key.DirectionLeft; "RIGHT" -> Key.DirectionRight - "BACK" -> Key.Back; else -> Key.DirectionCenter - } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt deleted file mode 100644 index fdab628e..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingCacheTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.justinvisser.wolfskin.design - -import java.util.Locale -import org.junit.Assert.* -import org.junit.Test - -/** Cache boundaries protect visible ratings during offline browsing and fast repeated focus. */ -class HomeRatingCacheTest { - private val title = HomeTitle("jellyfin-1", "A film", "movie", tmdbId = 42) - private val ratings = listOf(HomeRating("tmdb", 7.8)) - - @Test fun cacheIsSharedByCatalogueIdentityAndExpiresAtSixHours() { - var now = 0L - val cache = HomeRatingCache { now } - cache.save(title, ratings) - assertEquals(ratings, cache.read(title.copy(id = "another-server-copy"))) - now = 6L * 60 * 60 * 1000 - 1 - assertEquals(ratings, cache.read(title)) - now++ - assertNull(cache.read(title)) - } - - @Test fun cacheKeepsObservedAbsenceAndEvictsOldestBeyondCapacity() { - var now = 0L - val cache = HomeRatingCache { now++ } - cache.save(title, emptyList()) - assertEquals(emptyList(), cache.read(title)) - repeat(300) { index -> cache.save(title.copy(id = "other-$index", tmdbId = index + 100), ratings) } - assertNull(cache.read(title)) - assertEquals(ratings, cache.read(title.copy(id = "last", tmdbId = 399))) - } - - @Test fun episodeScoresShareSeriesSeasonIdentityWithoutCrossingEpisodesOrSeasons() { - var now = 0L - val cache = HomeRatingCache { now } - val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) - val pilot = listOf(HomeRating("tmdb", 8.1, episode = true)) - val second = listOf(HomeRating("tmdb", 7.2, episode = true)) - cache.save(episode, pilot) - cache.save(episode.copy(id = "episode-b", episode = 2), second) - assertEquals(pilot, cache.read(episode.copy(id = "another-jellyfin-copy"))) - assertEquals(second, cache.read(episode.copy(id = "another-second-copy", episode = 2))) - assertNull(cache.read(episode.copy(season = 2))) - assertNull(cache.read(episode.copy(tmdbId = 43))) - now = 6L * 60 * 60 * 1000 - assertNull(cache.read(episode)) - } - - @Test fun seasonCapacityCountsSeasonsNotEpisodesAndIsIndependentOfTitleCapacity() { - val cache = HomeRatingCache { 0L } - val episode = HomeTitle("episode-a", "Pilot", "episode", tmdbId = 42, season = 1, episode = 1) - repeat(301) { index -> cache.save(episode.copy(id = "episode-$index", episode = index + 1), ratings) } - repeat(300) { index -> cache.save(title.copy(tmdbId = index + 100), ratings) } - assertEquals(ratings, cache.read(episode)) - repeat(300) { index -> cache.save(episode.copy(season = index + 2), ratings) } - assertNull(cache.read(episode)) - assertEquals(ratings, cache.read(episode.copy(season = 301))) - assertEquals(ratings, cache.read(title.copy(tmdbId = 100))) - } - - @Test fun decimalScoresFollowLocaleButPercentagesDoNot() { - assertEquals("7,8", ratings.single().format(Locale.forLanguageTag("nl-NL"))) - assertEquals("7.8", ratings.single().format(Locale.US)) - assertEquals("87%", HomeRating("fresh", 87.9, percentage = true).format(Locale.forLanguageTag("nl-NL"))) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt deleted file mode 100644 index 664ecba6..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRatingsUiTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.foundation.layout.width -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import java.util.Locale -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.assertFalse -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Real text layout proves locale formatting and the focus dwell instead of testing copied strings. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class HomeRatingsUiTest { - @get:Rule val rule = createComposeRule() - - @Test fun dutchDecimalPlateStaysOnOneLine() { - val previous = Locale.getDefault() - Locale.setDefault(Locale.forLanguageTag("nl-NL")) - try { - rule.setContent { WolfskinTheme { HomeRatingPlate(listOf(HomeRating("tmdb", 7.8)), true, Modifier.width(WolfskinTokens.Card.posterW)) } } - val layouts = mutableListOf() - rule.onNodeWithText("7,8").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } - assertEquals(1, layouts.single().lineCount) - rule.onNodeWithText("7.8").assertDoesNotExist() - } finally { Locale.setDefault(previous) } - } - - @Test fun focusDwellDefersUncachedPlateButCachedFocusReturnsImmediately() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val title = HomeTitle("dwell", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8))) - val snapshot = HomeSnapshot(listOf(HomeRow("shelf", HomeRowKind.Shelf, "Shelf", listOf(title))), HomeAvailability.Ready) - rule.mainClock.autoAdvance = false - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) {} } } } - rule.mainClock.advanceTimeBy(100) - rule.onNodeWithText("7.8").assertDoesNotExist() - rule.mainClock.advanceTimeBy(450) - rule.onNodeWithText("7.8").assertExists() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } - rule.runOnIdle { assertTrue(shell.navFocused) } - rule.mainClock.advanceTimeBy(32) - rule.onNodeWithText("7.8").assertDoesNotExist() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } - rule.runOnIdle { assertFalse(shell.navFocused) } - rule.mainClock.advanceTimeBy(32) - rule.onNodeWithText("7.8").assertExists() - } - - @Test fun signedOutHomeNeverDrawsHeroOrPlateScores() { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val title = HomeTitle("signed-out", "A film", "movie", ratings = listOf(HomeRating("tmdb", 7.8))) - val rows = buildHomeRows(HomeLibrary(nextUp = listOf(title)), listOf(homeDiscoverState(HomeAvailability.SignedOut))) - rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(rows), modifier) {} } } } - rule.mainClock.advanceTimeBy(1000) - rule.onNodeWithText("7.8").assertDoesNotExist() - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt deleted file mode 100644 index 9fa7e9a9..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.justinvisser.wolfskin.design - -import org.junit.Assert.* -import org.junit.Test - -/** Hero precedence and empty/error transitions preserve useful, focusable Home content. */ -class HomeRowsTest { - private val resume = HomeTitle("resume", "Resume", "movie") - private val next = HomeTitle("next", "Next", "episode") - private val newest = HomeTitle("new", "New", "movie") - private val pick = HomeTitle("pick", "Pick", "movie") - private val continuation = listOf(HomeRow("pk-m", HomeRowKind.Shelf, cards = listOf(pick))) - - @Test fun heroChainUsesResumeThenNextThenNewThenPickAndNeverRepeatsHeroOnShelves() { - val cases = listOf( - HomeLibrary(listOf(resume), listOf(next), listOf(newest)) to resume, - HomeLibrary(nextUp = listOf(next), newest = listOf(newest)) to next, - HomeLibrary(newest = listOf(newest)) to newest, - HomeLibrary() to pick, - ) - for ((library, expected) in cases) { - val rows = buildHomeRows(library, continuation) - assertEquals(expected.id, rows.first().cards.single().id) - assertFalse(rows.filter { it.id in setOf("cw", "nu", "new") }.flatMap { it.cards }.any { it.id == expected.id }) - } - } - - @Test fun heldShelvesAreUnwatchedFirstAndSeeAllUsesFullDestinationCount() { - val watched = newest.copy(id = "watched", played = true, addedAt = "2026-11-05") - val older = newest.copy(id = "older", addedAt = "2026-01-01") - val recent = newest.copy(id = "recent", addedAt = "2026-10-01") - val rows = buildHomeRows(HomeLibrary(destinations = mapOf("movies" to listOf(watched, older, recent)), destinationTotals = mapOf("movies" to 84)), continuation) - val shelf = rows.single { it.id == "lib-movies" } - assertEquals(listOf("recent", "older", "watched"), shelf.cards.map { it.id }) - assertEquals(84, shelf.total) - assertTrue(shelf.hasSeeAll) - } - - @Test fun unavailableLibraryKeepsDiscoverAndFocusableRecoveryInsteadOfHeldShelves() { - val rows = buildHomeRows(HomeLibrary(listOf(resume), listOf(next), listOf(newest), mapOf("movies" to listOf(newest))), continuation, libraryAvailable = false) - assertEquals(listOf("hero", "jf-down", "disc-h", "pk-m"), rows.map { it.id }) - assertTrue(rows.single { it.id == "jf-down" }.focusable) - } - - @Test fun firstReadSkeletonHasNoFocusableContent() { - val rows = buildHomeRows(HomeLibrary(), continuation, loading = true) - assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind }) - assertTrue(rows.all { !it.focusable }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt deleted file mode 100644 index 8dd2e7f4..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import java.util.Locale -import org.junit.Before -import org.junit.After -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Each native screen state uses offline prototype data and real shell focus drawing. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class LibraryCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - private val previousLocale = Locale.getDefault() - private val fixture by lazy { libraryFixture() } - - @Before fun fullscreen() { - Locale.setDefault(Locale.US) - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - @After fun restoreLocale() { Locale.setDefault(previousLocale) } - - private fun capture(name: String, person: String = "shared", destination: String = "movies", catalogue: String? = null, ratings: Boolean = true, reachable: Boolean = true, cached: Boolean = false, configure: (LibraryGridState) -> Unit = {}) { - val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(destination) } - val source = when (catalogue) { - "picks" -> fixture.picks(person, "movie") - "trending" -> fixture.trending() - "duplicate" -> fixture.duplicatePage() - else -> fixture.library(person, destination) - } - val grid = LibraryGridState(if (catalogue == "picks") "Movies for you" else if (catalogue != null) "Trending" else destination.replaceFirstChar { it.uppercase() }, source, catalogue != null) - configure(grid) - shell.view.library = grid - rule.setContent { - WolfskinTheme { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> - LibraryScreen(shell, grid, modifier, ratingsSignedIn = ratings, ratingsReachable = reachable, - cachedRatings = if (cached) setOf(grid.items[grid.index].id) else emptySet(), offlineArt = ::fixtureArt) - } - } - } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "library").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - } - - @Test fun st021_sharedMovies() = capture("st-021") - @Test fun st022_ratingSort() = capture("st-022") { it.sort = 2 } - @Test fun st023_rowEndClamps() = capture("st-023") { it.index = 5; it.key(ShellKey.Right) } - @Test fun st024_anime() = capture("st-024", "justin", "anime") - @Test fun st025_unwatched() = capture("st-025") { it.unwatched = true } - @Test fun st026_empty() = capture("st-026", "mindel") { it.source = emptyList() } - @Test fun st027_unavailable() = capture("st-027", "mindel", "shows") { it.status = LibraryStatus.Unavailable } - @Test fun st028_loading() = capture("st-028") { it.status = LibraryStatus.Loading } - @Test fun st030_fullPicks() = capture("st-030", "justin", "discover", "picks") - @Test fun st035_duplicateOnlyTail() = capture("st-035", "justin", "discover", "duplicate") { it.tail = GridTail.ShowMore; it.index = it.tailIndex } - @Test fun st039_trending() = capture("st-039", "justin", "discover", "trending") - @Test fun st095_widestPosterPlate() = capture("st-095") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Grave of the Fireflies" } } - @Test fun st097_criticsPlate() = capture("st-097") - @Test fun st098_tmdbPlate() = capture("st-098", "justin", "anime") { grid -> grid.index = grid.items.indexOfFirst { it.title == "Chainsaw Man" } } - @Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true) - @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) - @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) - @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") - @Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 } - @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> - val original = grid.source - grid.source = List(200) { index -> - original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index") - } - grid.index = 4 - } -} - -fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/") - -fun fixtureArt(path: String): ImageBitmap? = LibraryCaptureTest::class.java.getResourceAsStream(path)?.use { BitmapFactory.decodeStream(it)?.asImageBitmap() } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt deleted file mode 100644 index 5eae18b5..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.KeyEvent as AndroidKeyEvent -import androidx.compose.runtime.remember -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.util.Locale - -/** Acceptance data drives Android keys through the real shell and grid controller. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class LibraryKeysTest { - @get:Rule val rule = createComposeRule() - private val shell = ShellState(fixturePeople(), "justin") - private val fixture by lazy { libraryFixture() } - private var opened: LibraryTitle? = null - - private fun render() { - rule.setContent { - WolfskinTheme { - ShellSurface(shell) { modifier -> - if (shell.route in listOf("movies", "shows", "anime")) { - val view = shell.view - val grid = remember(shell.person.key, shell.route, view) { - view.library ?: LibraryGridState(shell.route, fixture.library(shell.person.key, shell.route), onTitle = { opened = it }).also { view.library = it } - } - LibraryScreen(shell, grid, modifier, offlineArt = ::fixtureArt) - } else ShellPlaceholder(shell.route, modifier) - } - } - } - } - private fun press(key: Key) { rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }; rule.waitForIdle() } - private fun press(keys: List) { keys.forEach(::press) } - - @Test fun spiritedAwayInApprovedTwentyPresses() { - val document = LibraryKeysTest::class.java.getResourceAsStream("/library-screen.json")!!.bufferedReader().use { JSONObject(it.readText()) } - val paths = document.getJSONArray("pressPaths") - render() - for (index in 0 until paths.length()) { - val path = paths.getJSONObject(index) - val keys = path.getJSONArray("keys") - assertEquals(path.getInt("presses"), keys.length()) - for (keyIndex in 0 until keys.length()) press(when (keys.getString(keyIndex)) { - "UP" -> Key.DirectionUp - "DOWN" -> Key.DirectionDown - "LEFT" -> Key.DirectionLeft - "RIGHT" -> Key.DirectionRight - "OK" -> Key.DirectionCenter - else -> error("Unknown acceptance key") - }) - assertEquals("shared", shell.person.key) - assertEquals("movies", shell.route) - assertFalse(shell.navFocused) - assertEquals("Spirited Away", shell.view.library!!.focusedTitle!!.title) - } - } - - @Test fun rowEdgesToolbarSortAndBackKeepExactCard() { - render() - press(listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter)) - val grid = shell.view.library!! - press(Key.DirectionLeft) - assertEquals(0, grid.index) - repeat(6) { press(Key.DirectionRight) } - assertEquals(5, grid.index) - assertFalse(grid.inRail) - press(listOf(Key.DirectionDown, Key.Back)) - assertTrue(shell.navFocused) - assertEquals(11, grid.index) - press(Key.DirectionDown) - assertEquals(11, grid.index) - press(listOf(Key.DirectionUp, Key.DirectionUp)) - assertTrue(grid.toolbar) - press(Key.DirectionCenter) - assertEquals(1, grid.sort) - assertTrue(grid.toolbar) - press(Key.DirectionCenter) - assertEquals(2, grid.sort) - assertEquals("Grave of the Fireflies", grid.items.first().title) - } - - @Test fun cardActsOnReleaseAndHeldRepeatCannotOpenTwice() { - render() - press(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionRight, Key.DirectionCenter)) - rule.runOnIdle { - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - assertNull(opened) - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 1))) - assertNull(opened) - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - assertEquals(shell.view.library!!.focusedTitle, opened) - opened = null - shell.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - assertNull(opened) - } - } - - @Test fun railGateJumpAndBackRespectOriginalCard() { - val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") } - val grid = LibraryGridState("Movies", titles) - assertTrue(grid.railVisible) - grid.index = 4 - grid.key(ShellKey.Right) - assertTrue(grid.inRail) - grid.key(ShellKey.Down) - assertEquals('B', grid.railLetter) - grid.key(ShellKey.Back) - assertEquals(4, grid.index) - assertFalse(grid.inRail) - grid.key(ShellKey.Down) - assertEquals(9, grid.index) - grid.key(ShellKey.Up) - assertEquals(4, grid.index) - grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok) - assertEquals('B', grid.focusedTitle!!.letter) - grid.source = titles.take(199) - assertFalse(grid.railVisible) - grid.index = 4 - grid.key(ShellKey.Right) - assertEquals(5, grid.index) - grid.source = titles; grid.sort = 1 - assertFalse(grid.railVisible) - } - - @Test fun mixedSeeAllPreservesFullListAndClampsShortRow() { - val grid = LibraryGridState("Full list", (0 until 8).map { LibraryTitle("$it", "Title $it", if (it % 2 == 0) "movie" else "tv") }, catalogue = true) - grid.index = 5 - grid.key(ShellKey.Down) - assertEquals(7, grid.index) - grid.key(ShellKey.Up); grid.key(ShellKey.Up) - assertTrue(grid.toolbar) - grid.key(ShellKey.Right); grid.key(ShellKey.Ok) - assertEquals(listOf("0", "2", "4", "6"), grid.items.map { it.id }) - grid.key(ShellKey.Left); grid.key(ShellKey.Ok) - assertEquals(8, grid.items.size) - } - - @Test fun downClampsShortLastRowBeforeEnteringRecoveryTail() { - var recoveries = 0 - val grid = LibraryGridState("Trending", - (0 until 20).map { LibraryTitle("$it", "Title $it", "movie") }, - catalogue = true, onRecover = { recoveries++ }, - ).apply { tail = GridTail.Retry; index = 14 } - shell.pickerVisible = false - shell.enterDestination("discover") - rule.setContent { - WolfskinTheme { - ShellSurface(shell) { modifier -> - LibraryScreen(shell, grid, modifier) - } - } - } - press(Key.DirectionDown) - assertEquals(19, grid.index) - assertEquals("Title 19", grid.focusedTitle!!.title) - assertEquals(0, recoveries) - press(Key.DirectionDown) - assertEquals(24, grid.index) - assertEquals("grid-tail", grid.cardKey) - press(Key.DirectionDown) - assertEquals(24, grid.index) - press(Key.DirectionCenter) - assertEquals(1, recoveries) - press(Key.DirectionUp) - assertEquals(18, grid.index) - assertEquals("Title 18", grid.focusedTitle!!.title) - } - - @Test fun mixedCatalogueRowsKeepMovieAndSeriesWithTheSameNumericIdentityDistinct() { - val titles = List(12) { index -> - val type = if (index < 6) "movie" else "tv" - LibraryTitle((index % 6).toString(), "$type title ${index % 6}", type, tmdbId = (index % 6).toString()) - } - val grid = LibraryGridState("Trending", titles, catalogue = true, onTitle = { opened = it }) - shell.pickerVisible = false - shell.enterDestination("discover") - rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } - press(Key.DirectionDown) - assertEquals(6, grid.index) - press(Key.DirectionCenter) - assertEquals("tv", opened!!.type) - assertEquals("0", opened!!.tmdbId) - assertEquals("tv title 0", opened!!.title) - } - - @Test fun scoresFollowLocaleAndUnratedSortsLast() { - assertEquals("7,8", LibraryRating("tmdb", 7.8).formatted(Locale.forLanguageTag("nl-NL"))) - assertEquals("7.8", LibraryRating("tmdb", 7.8).formatted(Locale.US)) - assertEquals("87%", LibraryRating("rtCritics", 87.9).formatted(Locale.forLanguageTag("nl-NL"))) - val grid = LibraryGridState("Movies", listOf( - LibraryTitle("none", "The Apple", "movie"), - LibraryTitle("imdb", "A Bee", "movie", ratings = listOf(LibraryRating("imdb", 8.0))), - LibraryTitle("rt", "An Ant", "movie", ratings = listOf(LibraryRating("rtCritics", 80.0))), - )) - grid.sort = 2 - assertEquals(listOf("rt", "imdb", "none"), grid.items.map { it.id }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt deleted file mode 100644 index 6b0e41ba..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPaginationTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.justinvisser.wolfskin.design - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test - -/** Paging never loops through repeated pages or loses the readable list after a failure. */ -class LibraryPaginationTest { - private fun title(id: Int) = LibraryTitle("$id", "Title $id", "movie") - - @Test fun duplicateOnlyReadWaitsForExplicitRetryThenAppendsUniqueTitles() = runBlocking { - val grid = LibraryGridState("Trending", (0 until 20).map(::title), catalogue = true) - var calls = 0 - val pager = LibraryPagination(grid) { page -> - calls++ - when (page) { - 2 -> LibraryPage((0 until 20).map(::title), true) - 3 -> LibraryPage((18 until 25).map(::title), false) - else -> error("Unexpected extra read") - } - } - grid.index = 9 - pager.readAhead() - assertEquals(0, calls) - grid.index = 10 - pager.readAhead() - assertEquals(GridTail.ShowMore, grid.tail) - pager.readAhead() - assertEquals(1, calls) - pager.readAhead(force = true) - assertEquals((0 until 25).map { "$it" }, grid.source.map { it.id }) - assertEquals(GridTail.None, grid.tail) - pager.readAhead(force = true) - assertEquals(2, calls) - } - - @Test fun failedReadRetainsTitlesAndRetriesSamePage() = runBlocking { - val grid = LibraryGridState("Trending", (0 until 12).map(::title), catalogue = true) - var reachable = false - val pages = mutableListOf() - val pager = LibraryPagination(grid) { page -> - pages += page - if (!reachable) throw java.io.IOException("offline") - LibraryPage(listOf(title(12)), false) - } - grid.index = 11 - pager.readAhead() - assertEquals(GridTail.Retry, grid.tail) - assertEquals((0 until 12).map { "$it" }, grid.source.map { it.id }) - reachable = true - pager.readAhead(force = true) - assertEquals(listOf(2, 2), pages) - assertEquals("12", grid.source.last().id) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt deleted file mode 100644 index 79e89277..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class LibrarySpacingRegressionTest { - @get:Rule val rule = createComposeRule() - - @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { - val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) - rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } - val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot - val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot - assertEquals(256f, poster.width, .1f) - assertEquals(220f, track.width, .1f) - assertEquals(18f, track.left - poster.left, .1f) - assertEquals(18f, poster.right - track.right, .1f) - } - - @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { - val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } - val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 } - rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val poster = rule.onNodeWithTag("poster-198") - poster.assertIsDisplayed() - val original = poster.fetchSemanticsNode().boundsInRoot - for (count in listOf(200, 199)) { - rule.runOnIdle { grid.source = titles.take(count) } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - poster.assertIsDisplayed() - val current = poster.fetchSemanticsNode().boundsInRoot - assertEquals(198, grid.index) - assertEquals("198", grid.focusedTitle!!.id) - assertEquals(original.top, current.top, 1f) - assertEquals(original.height, current.height, 1f) - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt deleted file mode 100644 index 07dec9a8..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.TargetBasedAnimation -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.spring -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -/** Sample the shipped Compose spring, not a separately approximated motion function. */ -class NavSpringTest { - @Test - fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() { - val animation = TargetBasedAnimation( - animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness), - typeConverter = Float.VectorConverter, - initialValue = 478f, - targetValue = 688f, - ) - val times = listOf(50, 99, 150, 200) - val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f) - val values = times.map { animation.getValueFromNanos(it * 1_000_000L) } - values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) } - assertTrue((values[1] - 478) / 210 in .55f.. .8f) - assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f) - var previous = 478f - for (millisecond in 0..1000) { - val value = animation.getValueFromNanos(millisecond * 1_000_000L) - assertTrue("Spring reversed at $millisecond ms", value >= previous) - assertTrue("Spring overshot at $millisecond ms", value <= 688f) - previous = value - } - val directory = File(System.getProperty("wolfskin.captureDirectory")) - directory.mkdirs() - File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt deleted file mode 100644 index ec217257..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/OwnedSourceAuditTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import java.io.File -import org.junit.Assert.* -import org.junit.Test - -/** The normal JVM gate rejects untokened styling, undersized typography and internal screen wording. */ -class OwnedSourceAuditTest { - @Test fun ownedScreensUseTokensReadableTextAndViewerFacingNames() { - val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) - roots.forEach { assertTrue("Missing owned source root: $it", it.isDirectory) } - val violations = roots.flatMap { root -> - root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { source -> - audit(source.name, source.readText()).map { "$source:$it" } - }.toList() - } - assertTrue(violations.joinToString("\n"), violations.isEmpty()) - } - - @Test fun plantedViolationsFailEachDoneBarRule() { - val cases = listOf( - Triple("Screen.kt", "Box(Modifier.width(12.dp))", "unit"), - Triple("Screen.kt", "Box(Modifier.width(12f.dp))", "unit"), - Triple("Screen.kt", "Text(fontSize = 14.sp)", "unit"), - Triple("Screen.kt", "Box(Modifier.width((width + gap).dp))", "unit"), - Triple("Screen.kt", "Text(color = Color(0xFF123456))", "color"), - Triple("Screen.kt", "Text(color = ComposeColor(red = 1f, green = 0f, blue = 0f))", "color"), - Triple("WolfskinTokens.kt", "val tiny = TextStyle(fontSize = 11.5.sp)", "font-floor"), - Triple("Screen.kt", "Text(\"Recs Movies\")", "wording"), - Triple("Screen.kt", "Text(\"DGiC\")", "wording"), - Triple("Screen.kt", "Text(\"Justin Shows\")", "wording"), - Triple("Screen.kt", "Text(\"Next page\")", "wording"), - Triple("Screen.kt", "Text(\"Previous page\")", "wording"), - Triple("Screen.kt", "Text(\"Page 2\")", "wording"), - ) - cases.forEach { (name, source, rule) -> - assertTrue("$rule accepted $source", audit(name, source).any { it.endsWith(":$rule") }) - } - assertTrue(audit("WolfskinTokens.kt", "val body = TextStyle(fontSize = 12.sp); val fill = Color(0xFF123456)").isEmpty()) - assertTrue(audit("Screen.kt", "import androidx.compose.ui.unit.dp\nText(\"This title can’t be read\", style = WolfskinTokens.Type.body)").isEmpty()) - } - - private fun audit(name: String, source: String): List { - val generated = name == "WolfskinTokens.kt" - return source.lineSequence().mapIndexedNotNull { index, line -> - val code = line.trimStart() - if (code.startsWith("import ") || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) return@mapIndexedNotNull null - val rules = buildList { - if (!generated && units.containsMatchIn(line)) add("unit") - if (!generated && colors.containsMatchIn(line)) add("color") - if (fonts.findAll(line).any { it.groupValues[1].toDouble() < 12 }) add("font-floor") - if (strings.findAll(line).any { forbidden.containsMatchIn(it.value) }) add("wording") - } - rules.map { "${index + 1}:$it" } - }.flatten().toList() - } - - companion object { - private val units = Regex("\\.(?:dp|sp)\\b") - private val colors = Regex("\\b(?:Compose)?Color\\s*\\(\\s*(?:0[xX]|[0-9.]|(?:red|green|blue|alpha|color)\\s*=)") - private val fonts = Regex("\\bfontSize\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)[fF]?\\.sp\\b") - private val strings = Regex("\"(?:\\\\.|[^\"\\\\])*\"") - private val forbidden = Regex("Recs (?:Movies|Shows)|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page (?:[0-9]|\\$)|\\bpagination\\b", RegexOption.IGNORE_CASE) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt deleted file mode 100644 index f61d97e1..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.text.TextLayoutResult -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import java.util.Locale -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.* -import org.junit.Before -import org.junit.After -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class RecoveryCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - private val previousLocale = Locale.getDefault() - - @Before fun fullscreen() { - Locale.setDefault(Locale.US) - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - @After fun restoreLocale() { Locale.setDefault(previousLocale) } - - private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) } - - private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) { - rule.setContent { WolfskinTheme { - ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content) - } } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - } - - private fun capture(name: String) { - val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - val text = JSONArray() - val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]") - rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> - val layouts = mutableListOf() - node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts) - layouts.forEach { layout -> - val words = layout.layoutInput.text.text - val style = layout.layoutInput.style - assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f) - assertFalse("Internal wording: $words", forbidden.containsMatchIn(words)) - text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight) - .put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL) - .put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height)) - } - } - File(directory, "$name-layout.json").writeText(text.toString(2)) - } - - @Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() { - val shell = shell() - val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline") - shell.toast = RecoveryMessage.PlaybackUnavailable - mount(shell) { HomeScreen(shell, snapshot, it) {} } - rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() - rule.onNodeWithText("Resume S4 E9").assertIsDisplayed() - assertFalse(shell.navFocused) - val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot - val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot - assertEquals(1064f, toast.left, 1f) - assertEquals(150f, toast.top, 1f) - assertEquals(760f, toast.width, 1f) - assertEquals(1136f, message.left, 1f) - capture("st-090") - } - - @Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() { - var retried = false - val shell = shell("movies") - val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply { - savedAt = "20:42"; tail = GridTail.Retry - } - mount(shell) { LibraryScreen(shell, grid, it) } - assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } - assertTrue(retried) - capture("library-saved") - } - - @Test fun libraryWithoutSavedDataOffersRecovery() { - var retried = false - val shell = shell("shows") - val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable } - mount(shell) { LibraryScreen(shell, grid, it) } - rule.onNodeWithText("Try again").assertIsDisplayed() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - assertTrue(retried) - capture("library-unavailable") - } - - @Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() { - val shell = shell("discover") - val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42") - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) - mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } - rule.onNodeWithText("Saved 20:42").assertIsDisplayed() - assertTrue(state.hasHero) - assertFalse(state.canRequest) - rule.runOnIdle { state.focus(state.rowCount - 1) } - rule.mainClock.advanceTimeBy(1000) - rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed() - capture("discover-saved") - } - - @Test fun unavailableDiscoverRetryShowsExactFailureToast() { - val shell = shell("discover") - val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable) - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) - mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) } - rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed() - capture("discover-unavailable-retry") - } - - @Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() { - val shell = shell("settings") - val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting) - shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) - shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true)) - shell.openRequests() - mount(shell) { SettingsList(shell, it) } - rule.onNodeWithText("A saved request").assertIsDisplayed() - rule.onNodeWithText("Try again").assertIsDisplayed() - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) } - assertTrue(shell.requests.recoveryFocused) - capture("requests-saved") - } - - @Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() { - val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg") - mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) } - rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 } - capture("library-missing-art") - } - - @Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() { - Locale.setDefault(Locale.forLanguageTag("nl-NL")) - val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8))) - val shell = shell("movies") - mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) } - rule.onNodeWithText("7,8").assertIsDisplayed() - capture("library-dutch-score") - } - - @Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi") - @Test fun dutchDiscoverHeroScoreRendersComma() { - Locale.setDefault(Locale.forLanguageTag("nl-NL")) - val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8))) - val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) - val shell = shell("discover") - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) - mount(shell) { DiscoverScreen(state, it) } - rule.onNodeWithText("7,8").assertIsDisplayed() - capture("discover-dutch-score") - } - - @Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() { - val shell = shell("discover") - val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg") - val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) - val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate)) - mount(shell) { DiscoverScreen(state, it) } - rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() } - rule.onNodeWithTag("discover-missing-art").assertIsDisplayed() - capture("discover-missing-art") - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt deleted file mode 100644 index d0a98595..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class RecoveryStateTest { - @Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest { - val title = DiscoverTitle(1, "movie", "Saved film") - val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready) - var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable) - var writes = 0 - val operations = object : DiscoverOperations { - override suspend fun refresh() = response - override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) } - override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed) - override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true } - override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true } - } - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } - val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L }) - state.focus(0) - state.refresh() - testScheduler.runCurrent() - assertEquals(listOf(title), state.heroes) - assertEquals(0, state.focusedRow) - assertNotNull(state.snapshot.savedAt) - assertFalse(state.canRequest) - assertEquals(RecoveryMessage.StillUnreachable, state.toast) - state.hide(title) - testScheduler.runCurrent() - assertEquals(0, writes) - val recovery = state.lanes.last() - state.focus(state.lanes.indexOf(recovery) + state.firstShelf) - assertEquals(DiscoverTail.Retry, state.focusedLane?.tail) - response = initial - state.key(ShellKey.Ok) - testScheduler.runCurrent() - assertTrue(state.canRequest) - assertNull(state.snapshot.savedAt) - assertNull(state.toast) - } - - @Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest { - val initial = discoverTestFixture().snapshot("justin") - val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin") - val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope) - state.refresh() - testScheduler.runCurrent() - assertFalse(state.isSignedIn) - assertTrue(state.heroes.isEmpty()) - assertTrue(state.lanes.isEmpty()) - } - - @Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest { - val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } - val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading } - val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {} - val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope) - for (content in listOf(loading, home, discover)) { - shell.contentFocus = content - shell.focusNav(shell.currentTab) - shell.key(ShellKey.Down) - assertTrue("Skeleton-only content must retain nav focus", shell.navFocused) - } - loading.status = LibraryStatus.Unavailable - shell.contentFocus = loading - shell.key(ShellKey.Down) - assertFalse("A recovery button is reachable once loading fails", shell.navFocused) - } - - @Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() { - var retried = false - var opened = false - val shell = ShellState(fixturePeople(), "justin") { - if (it == ShellAction.RetryRequests) retried = true - if (it is ShellAction.RequestTitle) opened = true - }.apply { pickerVisible = false; enterDestination("settings") } - val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting) - shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) - shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true)) - shell.openRequests() - assertEquals(listOf(request), shell.requests.snapshot.items) - assertEquals("20:42", shell.navSavedAt) - shell.key(ShellKey.Down) - assertTrue(shell.requests.recoveryFocused) - shell.key(ShellKey.Ok) - assertTrue(retried) - assertFalse(opened) - shell.key(ShellKey.Up) - assertFalse(shell.requests.recoveryFocused) - shell.key(ShellKey.Back) - assertFalse(shell.requestsVisible) - } - - @Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() { - val shell = ShellState(fixturePeople(), "justin") - val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting) - shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42")) - val original = shell.requests - shell.pickerIndex = 1 - shell.key(ShellKey.Ok) - shell.openRequests() - assertEquals("mindel", shell.person.key) - assertTrue(shell.requests.snapshot.items.isEmpty()) - assertNull(shell.navSavedAt) - assertNotSame(original, shell.requests) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt deleted file mode 100644 index 7f114a64..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsCaptureTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Every approved Requests state uses the actual shell overlay at native television density. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class RequestsCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - private fun capture(name: String, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) { - val state = ShellState(fixturePeople(), person) - state.pickerVisible = false - state.enterDestination("settings") - state.requests.update(snapshot) - state.openRequests() - if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) } - rule.setContent { - WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } - } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory")) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - } - - @Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true)) - @Test fun st082_stages() = capture("st-082", requestsFixture()) - @Test fun st083_unavailable() = capture("st-083", ComingInSnapshot()) - @Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading)) - @Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true)) - @Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true) - @Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared") - @Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true)) -} - -/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */ -fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot { - val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() } - val items = readComingInFixture(source) { name -> - RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() } - } - val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) } - return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true) -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt deleted file mode 100644 index 780a6575..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import java.util.Locale -import org.json.JSONArray -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class RequestsKeysTest { - @get:Rule val rule = createComposeRule() - - private fun mount(state: ShellState) { - rule.setContent { WolfskinTheme { ShellSurface(state) } } - rule.mainClock.advanceTimeByFrame() - } - - private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) { - listOfNotNull(first, second, third, fourth).forEach { key -> - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } - rule.mainClock.advanceTimeByFrame() - } - } - - private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState = - ShellState(fixturePeople(), "justin", actions).apply { - pickerVisible = false - enterDestination("settings") - requests.update(snapshot) - openRequests() - } - - @Test fun approvedNinePressPathUsesRealShellFocus() { - val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) } - val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft, - "RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown) - val state = ShellState(fixturePeople(), "justin") - state.requests.update(requestsFixture()) - mount(state) - val path = paths.getJSONObject(0) - val presses = path.getJSONArray("keys") - assertEquals(9, path.getInt("presses")) - assertEquals(path.getInt("presses"), presses.length()) - for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index))) - rule.onNodeWithTag("coming-in").assertExists() - rule.onNodeWithText("Requests · 5 open").assertExists() - assertEquals("settings", state.route) - assertEquals("x5", state.requests.focusedId) - assertFalse(state.navFocused) - } - - @Test fun readinessWindowAndTitleRoundTripPreserveRequest() { - lateinit var state: ShellState - state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") } - mount(state) - send(Key.DirectionDown, Key.DirectionRight) - rule.onNodeWithText("Download · Downloading").assertExists() - assertEquals(4, state.requests.windowSize) - assertEquals("x1", state.requests.focusedId) - send(Key.DirectionLeft) - rule.onNodeWithText("Download · Downloading").assertDoesNotExist() - send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown) - assertEquals("x6", state.requests.focusedId) - assertEquals(1, state.requests.windowStart) - send(Key.DirectionDown) - assertEquals("x6", state.requests.focusedId) - send(Key.DirectionCenter) - assertFalse(state.requestsVisible) - assertTrue(state.route.startsWith("title/")) - send(Key.Back) - rule.onNodeWithTag("coming-row-x6").assertExists() - assertEquals("x6", state.requests.focusedId) - assertTrue(state.requestsVisible) - send(Key.Back) - assertFalse(state.requestsVisible) - assertEquals("settings", state.route) - assertEquals(0, state.settingsRow) - } - - @Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() { - var retries = 0 - val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ } - mount(state) - send(Key.DirectionUp, Key.DirectionLeft) - assertFalse(state.navFocused) - assertTrue(state.requestsVisible) - send(Key.DirectionCenter) - assertFalse(state.requestsVisible) - rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() } - send(Key.DirectionCenter) - assertEquals(1, retries) - assertTrue(state.requestsVisible) - rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } - send(Key.DirectionDown, Key.DirectionCenter) - rule.onNodeWithText("Nothing coming in.").assertExists() - assertTrue(state.requestsVisible) - } - - @Test fun cachedRatingsRenderInTheTelevisionLocale() { - val original = Locale.getDefault() - try { - Locale.setDefault(Locale.forLanguageTag("nl-NL")) - val state = sheet(requestsFixture(ratings = true)) - kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() } - mount(state) - rule.onNodeWithText("7,6").assertExists() - rule.onNodeWithText("87%").assertExists() - rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) } - rule.onNodeWithText("7,6").assertDoesNotExist() - rule.onNodeWithText("87%").assertDoesNotExist() - } finally { Locale.setDefault(original) } - } - - @Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() { - lateinit var state: ShellState - state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("title/${action.request.mediaType}:${action.request.tmdbId}") } - mount(state) - send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter) - assertEquals("home", state.route) - send(Key.Back) - assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible) - assertTrue(state.navFocused) - } - - @Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() { - val snapshot = requestsFixture() - val state = sheet(snapshot) - mount(state) - send(Key.DirectionDown) - rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) } - assertEquals("x1", state.requests.focusedId) - rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists() - val shared = requestsFixture(shared = true).items.single() - val viewer = fixturePeople().single { it.key == "shared" } - assertEquals("Shared", shared.actor(viewer)) - assertEquals("Justin", shared.recipient(viewer)) - assertEquals(5, snapshot.openCount) - assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank }) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt deleted file mode 100644 index 3774962c..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.text.TextLayoutResult -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.json.JSONObject -import org.junit.Before -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class ShellCaptureTest { - @get:Rule val rule = createAndroidComposeRule() - - @Before - fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - } - - private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) { - val state = ShellState(fixturePeople(), "justin") - configure(state) - rule.setContent { - WolfskinTheme { - ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests) - } - } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory")) - directory.mkdirs() - rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) - } - - @Test fun st001_launch() = capture("st-001") - @Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) } - @Test fun st002_gear() { - capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } - val layouts = mutableListOf() - rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } - assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount) - } - @Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading } - @Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable } - @Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true } - @Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 } - @Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 } - @Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") } - @Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") } - @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } -} - -/** Tests and debug builds share the same shell-only projection of the locked fixture. */ -fun fixturePeople(): List { - val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) } - val people = json.getJSONArray("profiles") - return List(people.length()) { index -> - val person = people.getJSONObject(index) - ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt deleted file mode 100644 index 77052d6f..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.view.KeyEvent as AndroidKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performKeyInput -import androidx.compose.ui.test.pressKey -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class ShellKeysTest { - @get:Rule val rule = createComposeRule() - private val state = ShellState(fixturePeople(), "justin") - private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter) - - private fun replay(keys: List, presses: Int) { - assertEquals("Acceptance press count", presses, keys.size) - rule.setContent { WolfskinTheme { ShellSurface(state) } } - keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } - } - - @Test fun switchPersonInSixPresses() { - replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6) - assertEquals("mindel", state.person.key) - assertEquals("home", state.route) - assertFalse(state.pickerVisible) - assertFalse(state.navFocused) - assertFalse(state.tabs.contains("anime")) - } - - @Test fun openSettingsInEightPresses() { - replay(settings, 8) - assertEquals("settings", state.route) - assertEquals(0, state.settingsRow) - assertFalse(state.pickerVisible) - assertFalse("Requests content, not the nav, must own focus", state.navFocused) - } - - @Test fun settingsBackBackInTenPresses() { - replay(settings + listOf(Key.Back, Key.Back), 10) - assertEquals("home", state.route) - assertFalse(state.pickerVisible) - assertTrue(state.navFocused) - assertEquals(state.tabs.size, state.navIndex) - } - - @Test fun playbackHandOffInElevenPresses() { - replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) - assertEquals("playback", state.handOff) - assertEquals(2, state.settingsRow) - } - - @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { - replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3) - assertFalse(state.gearFocused) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) } - assertTrue(state.gearFocused) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) } - assertFalse(state.gearFocused) - assertEquals(2, state.pickerIndex) - } - - @Test fun rootBackAndNavDownPreserveViewAndWrap() { - replay(listOf(Key.DirectionCenter), 1) - val view = state.view - rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) } - assertSame(view, state.view) - assertEquals(4, state.view.row) - assertEquals(2, state.view.cards["row-4"]) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) } - assertEquals(state.tabs.size, state.navIndex) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) } - assertEquals(0, state.navIndex) - } - - @Test fun destinationViewsAreIsolatedAndReselectResets() { - replay(listOf(Key.DirectionCenter), 1) - rule.runOnIdle { - state.enterDestination("movies") - val movies = state.view - movies.row = 3; movies.cards["movies"] = 2 - state.enterDestination("shows") - state.view.row = 1 - state.enterDestination("movies") - assertSame(movies, state.view) - state.focusNav(2); state.key(ShellKey.Ok) - assertNotSame(movies, state.view) - assertEquals(0, state.view.row) - assertTrue(state.view.cards.isEmpty()) - } - } - - @Test fun heldOkCannotActivateNewOverlay() { - replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3) - rule.runOnIdle { - state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) } - state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) - } - assertTrue(state.pickerVisible) - assertEquals("justin", state.person.key) - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt deleted file mode 100644 index a1762334..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellSnapshotReadTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import android.app.Application -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.test.junit4.createComposeRule -import kotlinx.coroutines.flow.first -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** A first read must work without composition prewarming the person's route memory. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class ShellSnapshotReadTest { - @get:Rule val rule = createComposeRule() - - @Test fun unseenViewCanBeReadInAReadOnlySnapshot() { - val shell = ShellState(fixturePeople(), "justin") - val snapshot = Snapshot.takeSnapshot() - try { - snapshot.enter { - assertEquals(0, shell.view.row) - assertTrue(shell.view.cards.isEmpty()) - assertNull(shell.view.savedAt) - assertEquals(0, shell.rememberedView("never-seen").row) - assertNotSame(shell.rememberedView("never-seen"), shell.rememberedView("never-seen")) - } - } finally { snapshot.dispose() } - } - - @Test fun launchedSnapshotFlowReadsDefaultsAndThenRetainsExplicitNavigationMemory() { - val shell = ShellState(fixturePeople(), "justin") - var observed: RememberedView? = null - rule.setContent { - LaunchedEffect(shell) { observed = snapshotFlow { shell.view }.first() } - } - rule.runOnIdle { - val home = requireNotNull(observed) - assertEquals(0, home.row) - assertTrue(home.cards.isEmpty()) - home.row = 3 - home.cards["shelf"] = 2 - shell.enterDestination("movies") - assertEquals(0, shell.view.row) - shell.view.row = 5 - shell.enterDestination("home") - assertSame(home, shell.view) - assertEquals(3, shell.view.row) - assertEquals(2, shell.view.cards["shelf"]) - shell.enterDestination("movies") - assertEquals(5, shell.view.row) - shell.focusPerson(1) - shell.key(ShellKey.Ok) - shell.enterDestination("movies") - assertEquals(0, shell.view.row) - shell.view.row = 7 - shell.pickerVisible = true - shell.focusPerson(0) - shell.key(ShellKey.Ok) - shell.enterDestination("movies") - assertEquals(5, shell.view.row) - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt deleted file mode 100644 index ab5fdd4d..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/SnapshotReadAuditTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.design - -import java.io.File -import org.junit.Assert.* -import org.junit.Test - -/** Guard known state writers; runtime tests cover transitive property reads. */ -class SnapshotReadAuditTest { - @Test fun snapshotObserversDoNotCallKnownStateWriters() { - val roots = listOf(File("src/main/java"), File("../app/src/wolfskin")) - val violations = roots.flatMap { root -> - assertTrue("Missing owned source root: $root", root.isDirectory) - root.walkTopDown().filter { it.isFile && it.extension == "kt" }.flatMap { file -> - observerBodies(file.readText()).filter { writingCalls.containsMatchIn(it) }.map { "$file: $it" } - }.toList() - } - assertTrue(violations.joinToString("\n"), violations.isEmpty()) - } - - @Test fun shellReadAccessorDoesNotInsertMemory() { - val source = File("src/main/java/org/justinvisser/wolfskin/design/ShellState.kt").readText() - val accessor = source.substringAfter("fun rememberedView(").substringBefore("/**") - assertFalse("rememberedView must not insert into snapshot state", Regex("getOrPut|\\.put\\s*\\(|\\]\\s*=").containsMatchIn(accessor)) - } - - @Test fun knownWriterIsRejectedWithoutRejectingItsCollector() { - val unsafe = observerBodies("snapshotFlow { state.focus(2) }").single() - assertTrue(writingCalls.containsMatchIn(unsafe)) - val safe = observerBodies("snapshotFlow { state.focusedLane?.id to state.column }.collect { state.focus(2) }").single() - assertFalse(writingCalls.containsMatchIn(safe)) - } - - private fun observerBodies(source: String): List = Regex("snapshotFlow\\s*\\{").findAll(source).map { match -> - val start = match.range.last + 1 - var depth = 1 - var end = start - while (end < source.length && depth > 0) { - when (source[end]) { '{' -> depth++; '}' -> depth-- } - end++ - } - source.substring(start, end - 1) - }.toList() - - private val writingCalls = Regex("\\b(?:retainView|preparePersonMemory|enterDestination|focus|updateSnapshot|updateLanes|invalidateLanes|updateRatings|recordCenter|getOrPut|put|putAll|clear|remove)\\s*\\(") -} diff --git a/wolfskin-design/src/test/resources/discover-keys.json b/wolfskin-design/src/test/resources/discover-keys.json deleted file mode 100644 index d1328e10..00000000 --- a/wolfskin-design/src/test/resources/discover-keys.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "pressPaths": [ - { - "goal": "Open Discover", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK" - ], - "presses": 4, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Request a pick for yourself", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK", - "OK", - "OK", - "OK" - ], - "presses": 7, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Request a pick for Mindel", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK", - "OK", - "RIGHT", - "OK", - "OK", - "OK" - ], - "presses": 9, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Round 3 · Not interested on a pick (Discover hero)", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK", - "OK", - "RIGHT", - "RIGHT", - "OK" - ], - "presses": 8, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Round 4 · Discover pick nobody holds → its Trailer (sample hand-off)", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK", - "OK", - "RIGHT", - "RIGHT", - "RIGHT", - "OK" - ], - "presses": 9, - "note": "4 on the title page (3 Rights to Trailer, OK); The Amazing Spider-Man", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05", - "requires": "Conductarr trailer field (spec 3.2, fifth addition); the press path runs once it lands" - } - ] -} diff --git a/wolfskin-design/src/test/resources/library-screen.json b/wolfskin-design/src/test/resources/library-screen.json deleted file mode 100644 index 07f33c34..00000000 --- a/wolfskin-design/src/test/resources/library-screen.json +++ /dev/null @@ -1,494 +0,0 @@ -{ - "id": "library", - "title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)", - "specSection": "6.4", - "source": "index.html:1403-1426, 1563-1611", - "layout": [ - { - "id": "lhead", - "type": "lhead", - "token": null, - "title": "Movies", - "yPx": 0, - "heightPx": 236, - "cards": null, - "focusable": false, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "ltools", - "type": "chips", - "token": null, - "title": "", - "yPx": 236, - "heightPx": 136, - "cards": null, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g0", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 372, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g1", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 896, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g2", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 1420, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g3", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 1944, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g4", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 2468, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g5", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 2992, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g6", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 3516, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g7", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 4040, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g8", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 4564, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g9", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 5088, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g10", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 5612, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g11", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 6136, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g12", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 6660, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - }, - { - "id": "g13", - "type": "grid", - "token": "poster", - "title": "", - "yPx": 7184, - "heightPx": 524, - "cards": 6, - "focusable": true, - "endsInSeeAll": false, - "sampleFill": false - } - ], - "states": [ - { - "id": "st-021", - "name": "Shared Movies: 84, continuous grid", - "statesGroup": "Libraries", - "capture": "st-021-shared-movies-84-continuous-grid.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-021-shared-movies-84-continuous-grid.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Shared Movies: 84, continuous grid · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" - }, - { - "id": "st-022", - "name": "Shared Movies sorted by Rating (G10)", - "statesGroup": "Libraries", - "capture": "st-022-shared-movies-sorted-by-rating-g10.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-022-shared-movies-sorted-by-rating-g10.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Shared Movies sorted by Rating (G10) · Person: Shared · Screen: Movies · Focus: g0 › Grave of the Fireflies" - }, - { - "id": "st-023", - "name": "Right at a row end clamps (no letter rail below 200)", - "statesGroup": "Libraries", - "capture": "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Right at a row end clamps (no letter rail below 200) · Person: Shared · Screen: Movies · Focus: g0 › Bohemian Rhapsody" - }, - { - "id": "st-024", - "name": "Justin Anime: 18 series", - "statesGroup": "Libraries", - "capture": "st-024-justin-anime-18-series.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-024-justin-anime-18-series.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "justin", - "focusReadout": "State: Justin Anime: 18 series · Person: Justin · Screen: Anime · Focus: g0 › Bleach" - }, - { - "id": "st-025", - "name": "Unwatched filter", - "statesGroup": "Libraries", - "capture": "st-025-unwatched-filter.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-025-unwatched-filter.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Unwatched filter · Person: Shared · Screen: Movies · Focus: g0 › Annie Hall" - }, - { - "id": "st-026", - "name": "Empty destination", - "statesGroup": "Libraries", - "capture": "st-026-empty-destination.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-026-empty-destination.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "mindel", - "focusReadout": "State: Empty destination · Person: Mindel · Screen: Movies · Focus: Nothing in Movies yet › Open Discover" - }, - { - "id": "st-027", - "name": "Jellyfin unreachable (error)", - "statesGroup": "Libraries", - "capture": "st-027-jellyfin-unreachable-error.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-027-jellyfin-unreachable-error.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "mindel", - "focusReadout": "State: Jellyfin unreachable (error) · Person: Mindel · Screen: Shows · Focus: Couldn’t reach your library › Try again" - }, - { - "id": "st-028", - "name": "Loading (skeleton, no saved copy)", - "statesGroup": "Libraries", - "capture": "st-028-loading-skeleton-no-saved-copy.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-028-loading-skeleton-no-saved-copy.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "nav", - "person": "shared", - "focusReadout": "State: Loading (skeleton, no saved copy) · Person: Shared · Screen: Movies · Focus: nav › Movies" - }, - { - "id": "st-095", - "name": "Plate: widest real set on a 256 px poster", - "statesGroup": "Ratings: one-line plate (round 3)", - "capture": "st-095-plate-widest-real-set-on-a-256-px-poster.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-095-plate-widest-real-set-on-a-256-px-poster.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Plate: widest real set on a 256 px poster · Person: Shared · Screen: Movies · Focus: g4 › Grave of the Fireflies" - }, - { - "id": "st-097", - "name": "Poster: RT critics on a library grid (Shared Movies)", - "statesGroup": "Ratings (round 2)", - "capture": "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Poster: RT critics on a library grid (Shared Movies) · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" - }, - { - "id": "st-098", - "name": "Poster: TMDB only (Justin Anime)", - "statesGroup": "Ratings (round 2)", - "capture": "st-098-poster-tmdb-only-justin-anime.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-098-poster-tmdb-only-justin-anime.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "justin", - "focusReadout": "State: Poster: TMDB only (Justin Anime) · Person: Justin · Screen: Anime · Focus: g0 › Chainsaw Man" - }, - { - "id": "st-112", - "name": "Conductarr down: a cached rating still shows", - "statesGroup": "Ratings (round 2)", - "capture": "st-112-conductarr-down-a-cached-rating-still-shows.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-112-conductarr-down-a-cached-rating-still-shows.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Conductarr down: a cached rating still shows · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" - }, - { - "id": "st-113", - "name": "Conductarr down: an uncached title shows nothing", - "statesGroup": "Ratings (round 2)", - "capture": "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg", - "captureUrl": "https://pages.justinvisser.org/wolfskin/tv-spec/img/st-113-conductarr-down-an-uncached-title-shows-nothing.jpg", - "screenKind": "library", - "overlay": null, - "focusZone": "content", - "person": "shared", - "focusReadout": "State: Conductarr down: an uncached title shows nothing · Person: Shared · Screen: Movies · Focus: g0 › American Gangster" - } - ], - "focusMap": { - "entry": "first grid card (not the toolbar); returning: remembered card per person", - "zones": { - "toolbar": { - "up": "nav", - "down": "grid", - "left": "previous chip", - "right": "next chip", - "ok": "Sort cycles A–Z -> Recently added -> Rating; Filter toggles All / Unwatched; focus returns to the toolbar", - "back": "nav", - "source": "index.html:445" - }, - "grid": { - "up": "row above, same column (top row -> toolbar)", - "down": "row below, same column (shorter last row: its last card)", - "left": "previous column (column 0: stays)", - "right": "next column; at the row end: stays (no rail below 200 titles)", - "ok": "title page (key-up)", - "back": "nav, place kept", - "ratings": "one-line plate after 350 ms", - "source": "index.html:446" - }, - "letterRail": "only at >= 200 titles and A–Z sort: Right at a row end enters; Up/Down move letters and scroll live; OK or Left drops focus on that letter's first title; Back returns to where you were (index.html:1586-1605)" - }, - "exit": [ - "Up from toolbar -> nav", - "Back -> nav" - ] - }, - "pressPaths": [ - { - "goal": "Reach “Spirited Away” (68th of 84) in Shared Movies by scrolling", - "keys": [ - "RIGHT", - "RIGHT", - "OK", - "UP", - "RIGHT", - "RIGHT", - "OK", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "RIGHT", - "RIGHT" - ], - "presses": 20, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - } - ], - "data": [ - { - "component": "Library grid", - "source": "Jellyfin /Users/{id}/Items?ParentId=…&SortBy=SortName over the destination's libraries, read in full in 200-item chunks ahead of focus", - "fields": [ - "SortName", - "ProductionYear", - "ImageTags.Primary", - "UserData.Played", - "UserData.PlaybackPositionTicks", - "ProviderIds.Tmdb" - ], - "cache": "LKG snapshot per destination; refresh on open if older than 10 min", - "states": [ - "empty", - "Jellyfin unreachable (LKG + Saved chip, or state row)", - "missing art" - ], - "pageRow": "index.html:469" - }, - { - "component": "Destinations", - "source": "Conductarr GET /curation/library-audiences (owner, media_type, is_anime, libraries[], users[]); until sign-in, the name rule (parked board mapping-source = name-rule)", - "pageRow": "index.html:468" - } - ], - "strings": { - "count": "{n} films | {n} series", - "empty": [ - "Nothing in {Destination} yet", - "Titles you or others request for you land here.", - "Open Discover" - ], - "emptyUnwatched": [ - "Nothing unwatched in {Destination}", - "Everything here has been watched.", - "Show all" - ], - "down": [ - "Couldn’t reach your library", - "Jellyfin isn’t answering. Nothing here is lost; try again in a moment.", - "Try again" - ], - "sort": [ - "A–Z", - "Recently added", - "Rating" - ], - "filter": [ - "All", - "Unwatched" - ] - }, - "ratingScoreFormat": { - "rule": "TMDB, IMDb and \"Ep\" scores (0-10) are formatted with the TV locale and exactly one decimal, e.g. String.format(Locale.getDefault(), \"%.1f\", x): 7,8 on a Dutch-locale box, 7.8 on an English one. RT percentages unchanged (87%). The page prints 7.8 always: a stand-in for the English locale, not authoritative.", - "acceptance": "on a Dutch-locale emulator a TMDB 7.8 renders 7,8 and the one-line plate rule (spec 4.8) still holds because the width is unchanged [INFERENCE: comma and point advance widths not measured]; on an English-locale emulator it renders 7.8 as in the captures", - "source": "Justin 2026-11-05, corr=c248698d8546e1ab; spec 4.7 rule 5, 10.1 check 8" - }, - "$schema": "wolfskin-tv-spec/screen@1", - "tokens": "data/tv-spec/tokens.json", - "captures": [ - "st-021-shared-movies-84-continuous-grid.jpg", - "st-022-shared-movies-sorted-by-rating-g10.jpg", - "st-023-right-at-a-row-end-clamps-no-letter-rail-below-200.jpg", - "st-024-justin-anime-18-series.jpg", - "st-025-unwatched-filter.jpg", - "st-026-empty-destination.jpg", - "st-027-jellyfin-unreachable-error.jpg", - "st-028-loading-skeleton-no-saved-copy.jpg", - "st-095-plate-widest-real-set-on-a-256-px-poster.jpg", - "st-097-poster-rt-critics-on-a-library-grid-shared-movies.jpg", - "st-098-poster-tmdb-only-justin-anime.jpg", - "st-112-conductarr-down-a-cached-rating-still-shows.jpg", - "st-113-conductarr-down-an-uncached-title-shows-nothing.jpg" - ] -} \ No newline at end of file diff --git a/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg deleted file mode 100644 index b9b72894..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg deleted file mode 100644 index f7444271..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-a-bronx-tale-1993-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg deleted file mode 100644 index 580b22f4..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-a-silent-voice-the-movie-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg deleted file mode 100644 index 3ce49112..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-absolution-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg deleted file mode 100644 index 52362b4e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-across-the-hall-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg deleted file mode 100644 index e9a03c14..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-addicted-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg deleted file mode 100644 index 8b65745b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-adulterers-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg deleted file mode 100644 index 8beb2f7f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-all-the-money-in-the-world-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg deleted file mode 100644 index 7ce6fa96..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-alone-with-her-2006-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg deleted file mode 100644 index 0f41d299..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-american-gangster-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg deleted file mode 100644 index 9023a691..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-annie-hall-1977-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg deleted file mode 100644 index 030addf0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg deleted file mode 100644 index 09cd6aa8..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-apocalypse-now-1979-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg deleted file mode 100644 index 62bd0afa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-appleseed-alpha-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg deleted file mode 100644 index 4550bdaa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg deleted file mode 100644 index fe224a15..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-arrival-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg deleted file mode 100644 index f57f1052..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg deleted file mode 100644 index b096fff2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg deleted file mode 100644 index 57df9298..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-avengers-infinity-war-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg deleted file mode 100644 index 7f59127b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-batman-ninja-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg deleted file mode 100644 index ce5623fe..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg deleted file mode 100644 index f8115170..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg deleted file mode 100644 index dde008b5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-black-adam-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg deleted file mode 100644 index 61c8860f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-black-bear-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg deleted file mode 100644 index 6fdaa3d2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg deleted file mode 100644 index d0219a75..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-black-panther-wakanda-forever-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg deleted file mode 100644 index 8d50255f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-blame-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg deleted file mode 100644 index d3619fbd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg deleted file mode 100644 index 20d2f9ee..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-bohemian-rhapsody-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg deleted file mode 100644 index 32fd8d4d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-braveheart-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg deleted file mode 100644 index b54cc5c7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-butterfly-on-a-wheel-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg deleted file mode 100644 index 92fd601f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-by-any-means-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg deleted file mode 100644 index 426142ef..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-civil-war-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg deleted file mode 100644 index bba96574..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-first-avenger-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg deleted file mode 100644 index c2dc5612..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-captain-america-the-winter-soldier-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg deleted file mode 100644 index b58ce600..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-careful-what-you-wish-for-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg deleted file mode 100644 index a6a99fee..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-castle-in-the-sky-1986-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg deleted file mode 100644 index 1def9414..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-chicago-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg deleted file mode 100644 index f26414a7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-citizen-kane-1941-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg deleted file mode 100644 index f3601b9b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-coherence-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg deleted file mode 100644 index c6aecefa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-conclave-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg deleted file mode 100644 index 4acfa871..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-crime-of-passion-1956-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg deleted file mode 100644 index 018d4f4b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-deep-water-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg deleted file mode 100644 index d93f3124..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-derailed-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg deleted file mode 100644 index d3444637..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-dream-house-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg deleted file mode 100644 index 3b563cf9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-dream-lover-1994-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg deleted file mode 100644 index 4cf7d66b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-drop-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg deleted file mode 100644 index 01a26f3b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-eastern-promises-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg deleted file mode 100644 index 45e13a32..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-easy-a-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg deleted file mode 100644 index 7a62df45..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg deleted file mode 100644 index c1bee971..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-emancipation-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg deleted file mode 100644 index 43f996bd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-faces-1968-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg deleted file mode 100644 index dda9d622..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-fatale-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg deleted file mode 100644 index 169c466d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-fighting-with-my-family-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg deleted file mode 100644 index 93ba0d71..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-force-of-nature-the-dry-2-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg deleted file mode 100644 index e69af7c0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-free-state-of-jones-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg deleted file mode 100644 index 264076e2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-from-up-on-poppy-hill-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg deleted file mode 100644 index 58d221ba..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg deleted file mode 100644 index ae262147..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg deleted file mode 100644 index 6a6aa651..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-gosford-park-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg deleted file mode 100644 index 1b71996a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-grave-of-the-fireflies-1988-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg deleted file mode 100644 index 042a6c80..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-howl-s-moving-castle-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg deleted file mode 100644 index e05bfdb0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-in-the-heights-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg deleted file mode 100644 index 41cc1eb5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-jennifer-s-body-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg deleted file mode 100644 index 80a59b1e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-kiki-s-delivery-service-1989-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg deleted file mode 100644 index 4c78fee9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg deleted file mode 100644 index 94ebe97d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-locke-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg deleted file mode 100644 index 423043cf..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-look-who-s-back-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg deleted file mode 100644 index f3725b42..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-memories-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg deleted file mode 100644 index cfc0d812..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-mercy-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg deleted file mode 100644 index c6fa6a26..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-night-is-short-walk-on-girl-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg deleted file mode 100644 index f4c89db7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-normal-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg deleted file mode 100644 index d6760468..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-outlaw-king-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg deleted file mode 100644 index 0b2a0d30..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-panic-room-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg deleted file mode 100644 index a4814472..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg deleted file mode 100644 index f548e831..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-ponyo-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg deleted file mode 100644 index 4a6a2bd1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-porco-rosso-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg deleted file mode 100644 index 21dd9359..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-princess-mononoke-1997-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg deleted file mode 100644 index 411b149b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-prisoners-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg deleted file mode 100644 index cc2ef2ce..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-racing-stripes-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg deleted file mode 100644 index e9ab449c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-reincarnated-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg deleted file mode 100644 index 6f89abc3..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-requiem-for-a-dream-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg deleted file mode 100644 index c79e70a1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg deleted file mode 100644 index 33408fa0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-secondhand-lions-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg deleted file mode 100644 index 0f6017ed..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-self-reliance-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg deleted file mode 100644 index 8ddd6e52..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-serpico-1973-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg deleted file mode 100644 index 6b2b79ce..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-shaft-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg deleted file mode 100644 index f200a28d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg deleted file mode 100644 index 428bb38b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg deleted file mode 100644 index a5ffff1f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-sleeping-with-other-people-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg deleted file mode 100644 index be1873fd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-snowpiercer-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg deleted file mode 100644 index 51fa155f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-brand-new-day-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg deleted file mode 100644 index 28521997..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-far-from-home-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg deleted file mode 100644 index 97fc9c05..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-homecoming-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg deleted file mode 100644 index bc92a4cd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-spider-man-no-way-home-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg deleted file mode 100644 index 5a79da62..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-spirited-away-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg deleted file mode 100644 index e85ddb90..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg deleted file mode 100644 index c410b981..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-taxi-driver-1976-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg deleted file mode 100644 index bb009cbf..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-alto-knights-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg deleted file mode 100644 index 4de2711f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg deleted file mode 100644 index c1aec876..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-amazing-spider-man-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg deleted file mode 100644 index 65553d47..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-animatrix-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg deleted file mode 100644 index be87067c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-avengers-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg deleted file mode 100644 index 094df64e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-boy-and-the-heron-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg deleted file mode 100644 index d1823120..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-breakfast-club-1985-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg deleted file mode 100644 index a0379f2f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-dry-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg deleted file mode 100644 index 94111d9b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-1972-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg deleted file mode 100644 index 1328c99e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-ii-1974-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg deleted file mode 100644 index abbe5e99..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-godfather-part-iii-1990-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg deleted file mode 100644 index e949fb3e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-great-gatsby-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg deleted file mode 100644 index f87e919f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-hours-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg deleted file mode 100644 index 83464d4c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg deleted file mode 100644 index 950e9ba1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-last-of-the-mohicans-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg deleted file mode 100644 index 0d58440c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-last-samurai-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg deleted file mode 100644 index 15f26533..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-lighthouse-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg deleted file mode 100644 index c292dce5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-1999-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg deleted file mode 100644 index ab11f50f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-reloaded-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg deleted file mode 100644 index 4477ef1e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-resurrections-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg deleted file mode 100644 index 7236cc4e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-matrix-revolutions-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg deleted file mode 100644 index c8582133..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-patriot-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg deleted file mode 100644 index fe50c2f8..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-secret-life-of-pets-2-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg deleted file mode 100644 index 72accfed..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-substance-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg deleted file mode 100644 index 882621cb..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg deleted file mode 100644 index 21976395..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-uprising-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg deleted file mode 100644 index 0894754d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-wild-robot-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg deleted file mode 100644 index b7688c77..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-wind-rises-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg deleted file mode 100644 index 6db33db1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg deleted file mode 100644 index 8a54bc7f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg deleted file mode 100644 index 5a4ff0ab..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-thelma-louise-1991-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg deleted file mode 100644 index aee092c6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-triangle-of-sadness-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg deleted file mode 100644 index 362ddd82..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-tsotsi-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg deleted file mode 100644 index e32685f3..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg deleted file mode 100644 index 76855126..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-vice-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg deleted file mode 100644 index 1351cad1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-vicky-cristina-barcelona-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg deleted file mode 100644 index 918e424f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-warriors-of-the-wind-1984-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg deleted file mode 100644 index a0d65904..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-we-bury-the-dead-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg deleted file mode 100644 index 3a123e80..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg deleted file mode 100644 index 96e3596e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-12-monkeys-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg deleted file mode 100644 index 64ca587b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-56-days-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg deleted file mode 100644 index 3362da59..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg deleted file mode 100644 index b4d256eb..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-90-day-fiance-the-other-way-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg deleted file mode 100644 index 79f1351c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg deleted file mode 100644 index afd371a9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-absolutely-fabulous-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg deleted file mode 100644 index 207b1dd1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-accel-world-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg deleted file mode 100644 index 74af010a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-accused-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg deleted file mode 100644 index 53a7ba33..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-adults-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg deleted file mode 100644 index 4fae4e33..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-after-life-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg deleted file mode 100644 index ea7dda99..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-agatha-christie-s-marple-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg deleted file mode 100644 index a29d6cb4..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-ahsoka-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg deleted file mode 100644 index 09ccfc33..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-all-american-homecoming-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg deleted file mode 100644 index 5b1666e6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-and-small-1978-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg deleted file mode 100644 index b506f990..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-all-creatures-great-small-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg deleted file mode 100644 index 4086bf17..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-american-crime-story-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg deleted file mode 100644 index 1f9e239a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-american-dad-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg deleted file mode 100644 index c02c46da..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-american-gods-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg deleted file mode 100644 index 5f92bf05..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-american-housewife-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg deleted file mode 100644 index 7f10e6aa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-american-primeval-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg deleted file mode 100644 index 9904c74b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-and-just-like-that-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg deleted file mode 100644 index df5dc498..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-andor-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg deleted file mode 100644 index 67b766b0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-andromeda-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg deleted file mode 100644 index e0c97321..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-aqua-teen-hunger-force-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg deleted file mode 100644 index 6a8f9a24..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg deleted file mode 100644 index 02b0dd43..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-arrested-development-2003-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg deleted file mode 100644 index adee3bb7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-arrow-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg deleted file mode 100644 index 5cd5f098..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-atlantis-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg deleted file mode 100644 index 61f459c6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-avatar-seven-havens-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg deleted file mode 100644 index 6f99263a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg deleted file mode 100644 index 9d46c2ff..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-avatar-the-last-airbender-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg deleted file mode 100644 index 8da6e8e2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-awkward-2011-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg deleted file mode 100644 index a12ac33e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-b-b-vol-liefde-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg deleted file mode 100644 index b00730a8..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-back-to-life-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg deleted file mode 100644 index f1c18f69..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-ballers-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg deleted file mode 100644 index ceef0c9d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-baretta-1975-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg deleted file mode 100644 index 386d3d90..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-based-on-a-true-story-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg deleted file mode 100644 index a864f50c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg deleted file mode 100644 index 7ca86592..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-batwoman-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg deleted file mode 100644 index fe11122d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-beat-bobby-flay-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg deleted file mode 100644 index fe264c76..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-benson-1979-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg deleted file mode 100644 index 93e1e86a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-big-boys-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg deleted file mode 100644 index ef024946..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-big-city-greens-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg deleted file mode 100644 index 4f535fec..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-big-mouth-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg deleted file mode 100644 index 97c885ad..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-black-ish-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg deleted file mode 100644 index 733f5cbc..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-black-lightning-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg deleted file mode 100644 index 3ff9aeed..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-black-rabbit-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg deleted file mode 100644 index b233e033..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-bleach-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg deleted file mode 100644 index 5e193ab2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-blue-planet-ii-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg deleted file mode 100644 index 73b77df2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-brassic-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg deleted file mode 100644 index c4f7217c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-breaking-bad-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg deleted file mode 100644 index 49c61208..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-breeders-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg deleted file mode 100644 index 38428e76..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-californication-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg deleted file mode 100644 index 466a1a7b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-call-the-midwife-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg deleted file mode 100644 index a3779772..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-chainsaw-man-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg deleted file mode 100644 index 7664f4b0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-chernobyl-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg deleted file mode 100644 index e75072ae..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-community-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg deleted file mode 100644 index 8d338532..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-cyberpunk-edgerunners-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg deleted file mode 100644 index f42cec16..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-dan-da-dan-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg deleted file mode 100644 index f5546d88..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-dead-end-job-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg deleted file mode 100644 index 56a8cd79..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-derry-girls-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg deleted file mode 100644 index 0c4c38bc..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-desperate-housewives-2004-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg deleted file mode 100644 index ac0b5900..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-dopesick-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg deleted file mode 100644 index 0783ed8c..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-dr-stone-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg deleted file mode 100644 index eef2a4f3..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-dune-prophecy-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg deleted file mode 100644 index 6f06f2c9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-east-of-eden-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg deleted file mode 100644 index f078bc89..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-etoile-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg deleted file mode 100644 index 5e87a07d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-fallout-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg deleted file mode 100644 index 53b406da..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-fawlty-towers-1975-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg deleted file mode 100644 index 9ad599b3..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-firefly-2002-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg deleted file mode 100644 index bd3ccea5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-fleabag-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg deleted file mode 100644 index cb94104e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg deleted file mode 100644 index 935e4944..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg deleted file mode 100644 index 23b668cb..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-gilmore-girls-2000-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg deleted file mode 100644 index 1fca7bf6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-ginny-georgia-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg deleted file mode 100644 index 41c051e0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-gravity-falls-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg deleted file mode 100644 index 2427108b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-hellcats-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg deleted file mode 100644 index 00a07ca7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-high-potential-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg deleted file mode 100644 index e80c7999..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-how-to-change-your-mind-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg deleted file mode 100644 index eb5fc487..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg deleted file mode 100644 index f3a21b66..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-interrogation-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg deleted file mode 100644 index c278e37e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-invincible-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg deleted file mode 100644 index 652923ee..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg deleted file mode 100644 index 45f5ff22..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-jujutsu-kaisen-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg deleted file mode 100644 index 062496fa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-kaiju-no-8-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg deleted file mode 100644 index 3d348798..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-landman-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg deleted file mode 100644 index a9789909..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-last-samurai-standing-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg deleted file mode 100644 index b1229ed8..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-lord-of-the-flies-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg deleted file mode 100644 index 08449aa9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-love-death-robots-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg deleted file mode 100644 index 62ccf357..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-love-is-blind-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg deleted file mode 100644 index a1985217..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-maniac-2018-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg deleted file mode 100644 index abe1b2e2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg deleted file mode 100644 index a8797fe5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-minx-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg deleted file mode 100644 index 925d5c49..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-mussolini-son-of-the-century-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg deleted file mode 100644 index cf571b93..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-my-hero-academia-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg deleted file mode 100644 index f86cd698..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-neon-genesis-evangelion-1995-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg deleted file mode 100644 index e7206b88..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-norsemen-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg deleted file mode 100644 index ae305043..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-offspring-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg deleted file mode 100644 index 286e6260..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-one-piece-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg deleted file mode 100644 index a6b147a4..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-only-murders-in-the-building-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg deleted file mode 100644 index 930943b0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-outlander-blood-of-my-blood-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg deleted file mode 100644 index 34b1ed84..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-over-the-garden-wall-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg deleted file mode 100644 index 25ceea88..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-peaky-blinders-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg deleted file mode 100644 index 1ea4711b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-pluribus-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg deleted file mode 100644 index 97d481d6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-record-of-ragnarok-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg deleted file mode 100644 index b95c4890..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-rick-and-morty-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg deleted file mode 100644 index de0b32dd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-ripley-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg deleted file mode 100644 index 296ac67a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-rivals-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg deleted file mode 100644 index b698f8a6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-rooster-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg deleted file mode 100644 index ed9358b3..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-sakamoto-days-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg deleted file mode 100644 index cf50fc09..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-say-nothing-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg deleted file mode 100644 index 1b6e67f6..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-secret-level-2024-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg deleted file mode 100644 index 2fb1c815..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-sentenced-to-be-a-hero-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg deleted file mode 100644 index 5a29d6f9..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-severance-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg deleted file mode 100644 index e546cc6d..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-sex-and-the-city-1998-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg deleted file mode 100644 index b493bc7b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-silo-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg deleted file mode 100644 index 559d6bd1..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-six-feet-under-2001-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg deleted file mode 100644 index 2540dad7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-skymed-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg deleted file mode 100644 index a2e91496..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-so-you-think-you-can-dance-2008-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg deleted file mode 100644 index d8568d54..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-south-park-1997-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg deleted file mode 100644 index fc326930..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-spy-x-family-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg deleted file mode 100644 index 4d254cb0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-100-2014-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg deleted file mode 100644 index 9beacde2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-afterparty-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg deleted file mode 100644 index 7e4e7be2..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-baby-sitters-club-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg deleted file mode 100644 index bbf0ab28..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-bear-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg deleted file mode 100644 index c78c5cf0..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-beast-in-me-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg deleted file mode 100644 index f663eb8b..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-bold-type-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg deleted file mode 100644 index f67ff16f..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-boys-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg deleted file mode 100644 index a4039011..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-capture-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg deleted file mode 100644 index 2eb48b62..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-diplomat-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg deleted file mode 100644 index d29448bb..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-durrells-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg deleted file mode 100644 index f0332826..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-final-problem-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg deleted file mode 100644 index fd252088..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-four-seasons-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg deleted file mode 100644 index 7bb38abd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-get-down-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg deleted file mode 100644 index 034273eb..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-hunting-wives-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg deleted file mode 100644 index 7da462da..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-last-of-us-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg deleted file mode 100644 index a7086d5e..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-legend-of-korra-2012-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg deleted file mode 100644 index dc387fcd..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg deleted file mode 100644 index 71407f36..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg deleted file mode 100644 index 7ab2dbf5..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-night-manager-2016-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg deleted file mode 100644 index ad1ec8ff..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-pitt-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg deleted file mode 100644 index 5324dc75..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-tatami-galaxy-2010-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg deleted file mode 100644 index 715d0c80..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-walk-in-2022-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg deleted file mode 100644 index 2f23d1fa..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-white-lotus-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg deleted file mode 100644 index f5eb8f59..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-the-witcher-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg deleted file mode 100644 index 8611eab4..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-undercover-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg deleted file mode 100644 index 547d3b7a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-upload-2020-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg deleted file mode 100644 index 7d0ba17a..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-vinland-saga-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg deleted file mode 100644 index 8c02a0d7..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-widow-s-bay-2026-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg deleted file mode 100644 index c7e16a57..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-yellowjackets-2021-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg b/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg deleted file mode 100644 index f5843766..00000000 Binary files a/wolfskin-design/src/test/resources/library/assets/tv-yu-yu-hakusho-1992-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/requests-press-paths.json b/wolfskin-design/src/test/resources/requests-press-paths.json deleted file mode 100644 index 5d76c663..00000000 --- a/wolfskin-design/src/test/resources/requests-press-paths.json +++ /dev/null @@ -1,21 +0,0 @@ -[ - { - "goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings", - "keys": [ - "OK", - "UP", - "LEFT", - "OK", - "RIGHT", - "RIGHT", - "DOWN", - "OK", - "OK" - ], - "presses": 9, - "note": "8 from the hero", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - } -] diff --git a/wolfskin-design/src/test/resources/wolfskin-discover.json b/wolfskin-design/src/test/resources/wolfskin-discover.json deleted file mode 100644 index 450f0ece..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-discover.json +++ /dev/null @@ -1 +0,0 @@ -{"schemaVersion": 1, "generatedAt": "2026-11-04T20:42:12.030108+00:00", "profiles": [{"key": "justin", "name": "Justin", "initial": "J", "color": "#62c4ff", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Anime": {"kind": "library", "collection": "anime"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-the-girl-who-leapt-through-time-2006", "title": "The Girl Who Leapt Through Time", "year": 2006, "type": "movie", "runtimeSeconds": 5886, "seasonCount": null, "communityRating": 7.772, "criticRating": 87, "genres": ["Fantasy", "Animation", "Drama"], "overview": "When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026", "poster": "assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg", "backdrop": "assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14069, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 76917, "tmdb": 7.775, "tmdbVotes": 2345}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.891, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-look-who-s-back-2015", "title": "Look Who's Back", "year": 2015, "type": "movie", "runtimeSeconds": 6936, "seasonCount": null, "communityRating": 6.793, "criticRating": null, "genres": ["Comedy"], "overview": "When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.", "poster": "assets/movie-look-who-s-back-2015-poster.jpg", "backdrop": "assets/movie-look-who-s-back-2015-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 318917, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 54672, "tmdb": 6.796, "tmdbVotes": 1990}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "movie-reincarnated-2012", "title": "Reincarnated", "year": 2012, "type": "movie", "runtimeSeconds": 5759, "seasonCount": null, "communityRating": 6.145, "criticRating": 28, "genres": ["Music", "Documentary"], "overview": "Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026", "poster": "assets/movie-reincarnated-2012-poster.jpg", "backdrop": "assets/movie-reincarnated-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 123431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 2563, "tmdb": 6.1, "tmdbVotes": 69}}, {"id": "movie-shipwrecked-nightmare-at-sea-2026", "title": "Shipwrecked: Nightmare at Sea", "year": 2026, "type": "movie", "runtimeSeconds": 5385, "seasonCount": null, "communityRating": 7.181, "criticRating": null, "genres": ["Documentary"], "overview": "Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.", "poster": "assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg", "backdrop": "assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T20:11:43.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1715492, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": 6.5, "imdbVotes": 5080, "tmdb": 7.172, "tmdbVotes": 87}}, {"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "shows": [{"id": "tv-andor-2022", "title": "Andor", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.293, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026", "poster": "assets/tv-andor-2022-poster.jpg", "backdrop": "assets/tv-andor-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0432996Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.321, "tmdbVotes": 2312}}, {"id": "tv-the-boys-2019", "title": "The Boys", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.443, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.", "poster": "assets/tv-the-boys-2019-poster.jpg", "backdrop": "assets/tv-the-boys-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0410131Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 13628}}, {"id": "tv-californication-2007", "title": "Californication", "year": 2007, "type": "tv", "runtimeSeconds": 1680, "seasonCount": 7, "communityRating": 7.632, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026", "poster": "assets/tv-californication-2007-poster.jpg", "backdrop": "assets/tv-californication-2007-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0428037Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1215, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.62, "tmdbVotes": 1406}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-fleabag-2016", "title": "Fleabag", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.278, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026", "poster": "assets/tv-fleabag-2016-poster.jpg", "backdrop": "assets/tv-fleabag-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:42:25.4848214Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 67070, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 1993}}, {"id": "tv-gravity-falls-2012", "title": "Gravity Falls", "year": 2012, "type": "tv", "runtimeSeconds": 1320, "seasonCount": 2, "communityRating": 8.619, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Comedy"], "overview": "Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.", "poster": "assets/tv-gravity-falls-2012-poster.jpg", "backdrop": "assets/tv-gravity-falls-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0435827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40075, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.624, "tmdbVotes": 3647}}, {"id": "tv-invincible-2021", "title": "INVINCIBLE", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.638, "criticRating": null, "genres": ["Animation", "Drama", "Sci-Fi & Fantasy"], "overview": "Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026", "poster": "assets/tv-invincible-2021-poster.jpg", "backdrop": "assets/tv-invincible-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.040159Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95557, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 6155}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-norsemen-2016", "title": "Norsemen", "year": 2016, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 3, "communityRating": 7.177, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.", "poster": "assets/tv-norsemen-2016-poster.jpg", "backdrop": "assets/tv-norsemen-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.034182Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 68126, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 233}}, {"id": "tv-rick-and-morty-2013", "title": "Rick and Morty", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 9, "communityRating": 8.679, "criticRating": null, "genres": ["Animation", "Comedy", "Sci-Fi & Fantasy"], "overview": "Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.", "poster": "assets/tv-rick-and-morty-2013-poster.jpg", "backdrop": "assets/tv-rick-and-morty-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0419581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60625, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 11548}}, {"id": "tv-secret-level-2024", "title": "Secret Level", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.411, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026", "poster": "assets/tv-secret-level-2024-poster.jpg", "backdrop": "assets/tv-secret-level-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0336417Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 261579, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.392, "tmdbVotes": 612}}, {"id": "tv-silo-2023", "title": "Silo", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.178, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026", "poster": "assets/tv-silo-2023-poster.jpg", "backdrop": "assets/tv-silo-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0346526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125988, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.232, "tmdbVotes": 2881}}, {"id": "tv-south-park-1997", "title": "South Park", "year": 1997, "type": "tv", "runtimeSeconds": null, "seasonCount": 29, "communityRating": 8.336, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.", "poster": "assets/tv-south-park-1997-poster.jpg", "backdrop": "assets/tv-south-park-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0456124Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2190, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.332, "tmdbVotes": 5250}}], "anime": [{"id": "tv-bleach-2004", "title": "Bleach", "year": 2004, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 17, "communityRating": 8.363, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026", "poster": "assets/tv-bleach-2004-poster.jpg", "backdrop": "assets/tv-bleach-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0359827Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 2331}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}, {"id": "tv-cyberpunk-edgerunners-2022", "title": "Cyberpunk: Edgerunners", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.466, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.", "poster": "assets/tv-cyberpunk-edgerunners-2022-poster.jpg", "backdrop": "assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2093107Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105248, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.507, "tmdbVotes": 2132}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-dr-stone-2019", "title": "Dr. STONE", "year": 2019, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.541, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026", "poster": "assets/tv-dr-stone-2019-poster.jpg", "backdrop": "assets/tv-dr-stone-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2091579Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86031, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.53, "tmdbVotes": 1814}}, {"id": "tv-frieren-beyond-journey-s-end-2023", "title": "Frieren: Beyond Journey's End", "year": 2023, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.803, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026", "poster": "assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg", "backdrop": "assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2094435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 209867, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.826, "tmdbVotes": 1016}}, {"id": "tv-fullmetal-alchemist-brotherhood-2009", "title": "Fullmetal Alchemist: Brotherhood", "year": 2009, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.7, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026", "poster": "assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg", "backdrop": "assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.3573939Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 31911, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 2633}}, {"id": "tv-jujutsu-kaisen-2020", "title": "JUJUTSU KAISEN", "year": 2020, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.583, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026", "poster": "assets/tv-jujutsu-kaisen-2020-poster.jpg", "backdrop": "assets/tv-jujutsu-kaisen-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207573Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.574, "tmdbVotes": 4735}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-my-hero-academia-2016", "title": "My Hero Academia", "year": 2016, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 8, "communityRating": 8.607, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.", "poster": "assets/tv-my-hero-academia-2016-poster.jpg", "backdrop": "assets/tv-my-hero-academia-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2089315Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65930, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 5426}}, {"id": "tv-neon-genesis-evangelion-1995", "title": "Neon Genesis Evangelion", "year": 1995, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 1, "communityRating": 8.568, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Animation", "Drama"], "overview": "At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026", "poster": "assets/tv-neon-genesis-evangelion-1995-poster.jpg", "backdrop": "assets/tv-neon-genesis-evangelion-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0340629Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 890, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.56, "tmdbVotes": 2205}}, {"id": "tv-record-of-ragnarok-2021", "title": "Record of Ragnarok", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.402, "criticRating": null, "genres": ["Action & Adventure", "Animation", "Sci-Fi & Fantasy"], "overview": "Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.", "poster": "assets/tv-record-of-ragnarok-2021-poster.jpg", "backdrop": "assets/tv-record-of-ragnarok-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2085766Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.403, "tmdbVotes": 1929}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-the-tatami-galaxy-2010", "title": "The Tatami Galaxy", "year": 2010, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.271, "criticRating": null, "genres": ["Animation", "Comedy", "Drama"], "overview": "When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026", "poster": "assets/tv-the-tatami-galaxy-2010-poster.jpg", "backdrop": "assets/tv-the-tatami-galaxy-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-18T05:52:51.0023256Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 36243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 152}}, {"id": "tv-vinland-saga-2019", "title": "Vinland Saga", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 8.527, "criticRating": null, "genres": ["Animation", "War & Politics", "Action & Adventure"], "overview": "For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026", "poster": "assets/tv-vinland-saga-2019-poster.jpg", "backdrop": "assets/tv-vinland-saga-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.207389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.509, "tmdbVotes": 1064}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}], "resume": [], "nextUp": [{"id": "episode-three-monsters-1992-s1e6", "title": "Three Monsters", "year": 1992, "type": "episode", "runtimeSeconds": 1421, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "Yusuke receives his first real case as Spirit Detective. Three demon thieves have muscled their way into King Yama's sacred vault, stealing the three Artifacts of Darkness. Yusuke must recover the artifacts and bring\u2026", "poster": "assets/episode-three-monsters-1992-s1e6-poster.jpg", "backdrop": "assets/episode-three-monsters-1992-s1e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:59:21.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Yu Yu Hakusho", "seasonNumber": 1, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-all-hands-on-deck-class-1-a-2021-s5e1", "title": "All Hands on Deck! Class 1-A", "year": 2021, "type": "episode", "runtimeSeconds": 1433, "seasonCount": null, "communityRating": 6.938, "criticRating": null, "genres": ["Animation", "Action", "Adventure"], "overview": "There's an emergency drill at U.A. High School, and Class 1-A is requested to report for duty!", "poster": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-poster.jpg", "backdrop": "assets/episode-all-hands-on-deck-class-1-a-2021-s5e1-backdrop.jpg", "played": false, "playCount": 2, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T04:04:50.0000000Z", "lastPlayedAt": "2026-10-19T17:47:09.3028025Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "My Hero Academia", "seasonNumber": 5, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-erickerhead-2026-s9e6", "title": "Erickerhead", "year": 2026, "type": "episode", "runtimeSeconds": 1357, "seasonCount": null, "communityRating": 8.196, "criticRating": null, "genres": [], "overview": "Rick's stomach is mad, broh. But it's all-you-can-eat-and-drink here, broh!", "poster": "assets/episode-erickerhead-2026-s9e6-poster.jpg", "backdrop": "assets/episode-erickerhead-2026-s9e6-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-28T10:23:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Rick and Morty", "seasonNumber": 9, "episodeNumber": 6, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-girls-interrupted-2007-s1e7", "title": "Girls, Interrupted", "year": 2007, "type": "episode", "runtimeSeconds": 1642, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Hank is disturbed by Becca's negative reaction towards the concept of happy endings, even more so when he finds out that it is partially his fault.\nMarcy comes up with the idea of a threesome with Charlie and Dani,\u2026", "poster": "assets/episode-girls-interrupted-2007-s1e7-poster.jpg", "backdrop": "assets/episode-girls-interrupted-2007-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-05T02:46:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Californication", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-announcement-2022-s1e7", "title": "Announcement", "year": 2022, "type": "episode", "runtimeSeconds": 3012, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Action", "Adventure", "Drama"], "overview": "The repercussions of the Aldhani heist reach all the way to Coruscant, while Cassian returns home.", "poster": "assets/episode-announcement-2022-s1e7-poster.jpg", "backdrop": "assets/episode-announcement-2022-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:56:24.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Andor", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-south-american-biker-gangs-2026-s29e1", "title": "South American Biker Gangs", "year": 2026, "type": "episode", "runtimeSeconds": 1335, "seasonCount": null, "communityRating": 6.4, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "When e-bikes arrive in South America, the boys take over the streets and the parents of South America can hardly recognize South America.", "poster": "assets/episode-south-american-biker-gangs-2026-s29e1-poster.jpg", "backdrop": "assets/episode-south-american-biker-gangs-2026-s29e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-01T06:40:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "South Park", "seasonNumber": 29, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "movie-steins-gate-the-movie-load-region-of-deja-vu-2013", "title": "Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu", "year": 2013, "type": "movie", "runtimeSeconds": 5403, "seasonCount": null, "communityRating": 7.8, "criticRating": null, "genres": ["Animation", "Mystery", "Sci-Fi"], "overview": "After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026", "poster": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg", "backdrop": "assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T14:55:54.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "tv-yu-yu-hakusho-1992", "title": "Yu Yu Hakusho", "year": 1992, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 4, "communityRating": 8.5, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"", "poster": "assets/tv-yu-yu-hakusho-1992-poster.jpg", "backdrop": "assets/tv-yu-yu-hakusho-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T21:46:01.5733049Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 30669, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.475, "tmdbVotes": 519}}, {"id": "movie-memories-1995", "title": "Memories", "year": 1995, "type": "movie", "runtimeSeconds": 6805, "seasonCount": null, "communityRating": 7.465, "criticRating": null, "genres": ["Fantasy", "Animation", "Science Fiction"], "overview": "Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.", "poster": "assets/movie-memories-1995-poster.jpg", "backdrop": "assets/movie-memories-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-30T21:36:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 42994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 26638, "tmdb": 7.459, "tmdbVotes": 622}}, {"id": "tv-firefly-2002", "title": "Firefly", "year": 2002, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.347, "criticRating": null, "genres": ["Drama", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026", "poster": "assets/tv-firefly-2002-poster.jpg", "backdrop": "assets/tv-firefly-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:40:08.4202369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1437, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 77, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.351, "tmdbVotes": 2550}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.205, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T11:31:20.8818757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-the-legend-of-korra-2012", "title": "The Legend of Korra", "year": 2012, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 4, "communityRating": 8.2, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026", "poster": "assets/tv-the-legend-of-korra-2012-poster.jpg", "backdrop": "assets/tv-the-legend-of-korra-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T00:19:23.2494945Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33880, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.221, "tmdbVotes": 2482}}, {"id": "tv-spy-x-family-2022", "title": "SPY x FAMILY", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.506, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.", "poster": "assets/tv-spy-x-family-2022-poster.jpg", "backdrop": "assets/tv-spy-x-family-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2670364Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 120089, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 2382}}, {"id": "tv-sentenced-to-be-a-hero-2025", "title": "Sentenced to Be a Hero", "year": 2025, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.338, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026", "poster": "assets/tv-sentenced-to-be-a-hero-2025-poster.jpg", "backdrop": "assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2666791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 249907, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.244, "tmdbVotes": 129}}, {"id": "tv-sakamoto-days-2025", "title": "SAKAMOTO DAYS", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.071, "criticRating": null, "genres": ["Animation", "Comedy", "Action & Adventure"], "overview": "Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.", "poster": "assets/tv-sakamoto-days-2025-poster.jpg", "backdrop": "assets/tv-sakamoto-days-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.266066Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.057, "tmdbVotes": 375}}, {"id": "tv-dan-da-dan-2024", "title": "Dan Da Dan", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.538, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Comedy"], "overview": "In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!", "poster": "assets/tv-dan-da-dan-2024-poster.jpg", "backdrop": "assets/tv-dan-da-dan-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2652252Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.517, "tmdbVotes": 986}}, {"id": "tv-kaiju-no-8-2024", "title": "Kaiju No. 8", "year": 2024, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 2, "communityRating": 8.436, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026", "poster": "assets/tv-kaiju-no-8-2024-poster.jpg", "backdrop": "assets/tv-kaiju-no-8-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-22T10:31:16.2646566Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207468, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 836}}, {"id": "tv-chainsaw-man-2022", "title": "Chainsaw Man", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 1, "communityRating": 8.515, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026", "poster": "assets/tv-chainsaw-man-2022-poster.jpg", "backdrop": "assets/tv-chainsaw-man-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T22:28:13.2095501Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 114410, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.508, "tmdbVotes": 2364}}], "discover": [{"id": "movie-the-amazing-spider-man-2012", "title": "The Amazing Spider-Man", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.", "poster": "assets/movie-the-amazing-spider-man-2012-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1930, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 791938, "tmdb": 6.768, "tmdbVotes": 19312}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-the-animatrix-2003", "title": "The Animatrix", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction"], "overview": "Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026", "poster": "assets/movie-the-animatrix-2003-poster.jpg", "backdrop": "assets/movie-the-animatrix-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 55931, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 91929, "tmdb": 7.221, "tmdbVotes": 1872}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-appleseed-alpha-2014", "title": "Appleseed Alpha", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026", "poster": "assets/movie-appleseed-alpha-2014-poster.jpg", "backdrop": "assets/movie-appleseed-alpha-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 269650, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 59, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 9689, "tmdb": 6.808, "tmdbVotes": 436}}, {"id": "movie-aquaman-and-the-lost-kingdom-2023", "title": "Aquaman and the Lost Kingdom", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026", "poster": "assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg", "backdrop": "assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 572802, "ratings": {"rtCritics": 33, "rtCriticsLabel": "Rotten", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 122422, "tmdb": 6.48, "tmdbVotes": 3748}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-avengers-confidential-black-widow-punisher-2014", "title": "Avengers Confidential: Black Widow & Punisher", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Science Fiction", "Action"], "overview": "When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.", "poster": "assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg", "backdrop": "assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 257346, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 5166, "tmdb": 6.368, "tmdbVotes": 303}}, {"id": "movie-avengers-age-of-ultron-2015", "title": "Avengers: Age of Ultron", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026", "poster": "assets/movie-avengers-age-of-ultron-2015-poster.jpg", "backdrop": "assets/movie-avengers-age-of-ultron-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 99861, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 1025876, "tmdb": 7.278, "tmdbVotes": 25289}}, {"id": "movie-avengers-infinity-war-2018", "title": "Avengers: Infinity War", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026", "poster": "assets/movie-avengers-infinity-war-2018-poster.jpg", "backdrop": "assets/movie-avengers-infinity-war-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 299536, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 1421954, "tmdb": 8.246, "tmdbVotes": 33331}}, {"id": "movie-batman-ninja-2018", "title": "Batman Ninja", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action", "Science Fiction"], "overview": "Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.", "poster": "assets/movie-batman-ninja-2018-poster.jpg", "backdrop": "assets/movie-batman-ninja-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 485942, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 24579, "tmdb": 5.9, "tmdbVotes": 1106}}, {"id": "movie-batman-v-superman-dawn-of-justice-2016", "title": "Batman v Superman: Dawn of Justice", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Fantasy"], "overview": "Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026", "poster": "assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg", "backdrop": "assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 209112, "ratings": {"rtCritics": 28, "rtCriticsLabel": "Rotten", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 801382, "tmdb": 6.014, "tmdbVotes": 19527}}, {"id": "movie-batman-the-dark-knight-returns-part-1-2012", "title": "Batman: The Dark Knight Returns, Part 1", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Animation"], "overview": "Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?", "poster": "assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg", "backdrop": "assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 123025, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 68943, "tmdb": 7.742, "tmdbVotes": 1766}}, {"id": "movie-black-adam-2022", "title": "Black Adam", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026", "poster": "assets/movie-black-adam-2022-poster.jpg", "backdrop": "assets/movie-black-adam-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 436270, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 309624, "tmdb": 6.747, "tmdbVotes": 7370}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-black-panther-wakanda-forever-2022", "title": "Black Panther: Wakanda Forever", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026", "poster": "assets/movie-black-panther-wakanda-forever-2022-poster.jpg", "backdrop": "assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 505642, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 405088, "tmdb": 6.984, "tmdbVotes": 7920}}, {"id": "movie-blame-2017", "title": "BLAME!", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Drama", "Science Fiction"], "overview": "In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026", "poster": "assets/movie-blame-2017-poster.jpg", "backdrop": "assets/movie-blame-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 409421, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 10258, "tmdb": 6.523, "tmdbVotes": 370}}, {"id": "movie-bleach-the-movie-fade-to-black-2008", "title": "Bleach the Movie: Fade to Black", "year": 2008, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Animation", "Fantasy"], "overview": "A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026", "poster": "assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg", "backdrop": "assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 17165, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 3693, "tmdb": 7.1, "tmdbVotes": 206}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-the-abbott-and-costello-show-1952", "title": "The Abbott and Costello Show", "year": 1952, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.", "poster": "assets/tv-the-abbott-and-costello-show-1952-poster.jpg", "backdrop": "assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 11821, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 22}}, {"id": "tv-accel-world-2012", "title": "Accel World", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Drama"], "overview": "A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026", "poster": "assets/tv-accel-world-2012-poster.jpg", "backdrop": "assets/tv-accel-world-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 43167, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.429, "tmdbVotes": 163}}, {"id": "tv-ahsoka-2023", "title": "Ahsoka", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.", "poster": "assets/tv-ahsoka-2023-poster.jpg", "backdrop": "assets/tv-ahsoka-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 114461, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 1351}}, {"id": "tv-american-dad-2005", "title": "American Dad!", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026", "poster": "assets/tv-american-dad-2005-poster.jpg", "backdrop": "assets/tv-american-dad-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1433, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.022, "tmdbVotes": 2481}}, {"id": "tv-american-gods-2017", "title": "American Gods", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.", "poster": "assets/tv-american-gods-2017-poster.jpg", "backdrop": "assets/tv-american-gods-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 46639, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 1852}}, {"id": "tv-andromeda-2000", "title": "Andromeda", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Drama", "Sci-Fi & Fantasy"], "overview": "Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.", "poster": "assets/tv-andromeda-2000-poster.jpg", "backdrop": "assets/tv-andromeda-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2985, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.068, "tmdbVotes": 315}}, {"id": "tv-aqua-teen-hunger-force-2000", "title": "Aqua Teen Hunger Force", "year": 2000, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Comedy"], "overview": "The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.", "poster": "assets/tv-aqua-teen-hunger-force-2000-poster.jpg", "backdrop": "assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 251, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 272}}, {"id": "tv-arifureta-from-commonplace-to-world-s-strongest-2019", "title": "Arifureta: From Commonplace to World's Strongest", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026", "poster": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg", "backdrop": "assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 86034, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.382, "tmdbVotes": 662}}, {"id": "tv-arrow-2012", "title": "Arrow", "year": 2012, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Action & Adventure"], "overview": "Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.", "poster": "assets/tv-arrow-2012-poster.jpg", "backdrop": "assets/tv-arrow-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1412, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.834, "tmdbVotes": 6537}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-batwoman-2019", "title": "Batwoman", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure"], "overview": "Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026", "poster": "assets/tv-batwoman-2019-poster.jpg", "backdrop": "assets/tv-batwoman-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 89247, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 1619}}, {"id": "tv-big-mouth-2017", "title": "Big Mouth", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Animation"], "overview": "Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.", "poster": "assets/tv-big-mouth-2017-poster.jpg", "backdrop": "assets/tv-big-mouth-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 74204, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.591, "tmdbVotes": 1274}}, {"id": "tv-black-lightning-2018", "title": "Black Lightning", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026", "poster": "assets/tv-black-lightning-2018-poster.jpg", "backdrop": "assets/tv-black-lightning-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 71663, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.907, "tmdbVotes": 991}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "mindel", "name": "Mindel", "initial": "M", "color": "#ff9d72", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-chicago-2002", "title": "Chicago", "year": 2002, "type": "movie", "runtimeSeconds": 6808, "seasonCount": null, "communityRating": 7.131, "criticRating": 87, "genres": ["Comedy", "Crime", "Drama"], "overview": "Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.", "poster": "assets/movie-chicago-2002-poster.jpg", "backdrop": "assets/movie-chicago-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 259272, "tmdb": 7.135, "tmdbVotes": 3025}}, {"id": "movie-coherence-2014", "title": "Coherence", "year": 2014, "type": "movie", "runtimeSeconds": 5286, "seasonCount": null, "communityRating": 7.21, "criticRating": 89, "genres": ["Thriller", "Science Fiction"], "overview": "Four couples gather for dinner the night a mysterious and powerful comet passes overhead.", "poster": "assets/movie-coherence-2014-poster.jpg", "backdrop": "assets/movie-coherence-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2017-08-12T23:49:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 220289, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 180354, "tmdb": 7.208, "tmdbVotes": 3702}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "movie-night-is-short-walk-on-girl-2017", "title": "Night Is Short, Walk On Girl", "year": 2017, "type": "movie", "runtimeSeconds": 5548, "seasonCount": null, "communityRating": 7.421, "criticRating": 90, "genres": ["Comedy", "Romance", "Animation"], "overview": "As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026", "poster": "assets/movie-night-is-short-walk-on-girl-2017-poster.jpg", "backdrop": "assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 430214, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 7640, "tmdb": 7.4, "tmdbVotes": 267}}, {"id": "movie-thelma-louise-1991", "title": "Thelma & Louise", "year": 1991, "type": "movie", "runtimeSeconds": 7786, "seasonCount": null, "communityRating": 7.596, "criticRating": 87, "genres": ["Drama", "Crime", "Adventure"], "overview": "Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026", "poster": "assets/movie-thelma-louise-1991-poster.jpg", "backdrop": "assets/movie-thelma-louise-1991-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1541, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 82, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 197947, "tmdb": 7.594, "tmdbVotes": 4063}}, {"id": "movie-vicky-cristina-barcelona-2008", "title": "Vicky Cristina Barcelona", "year": 2008, "type": "movie", "runtimeSeconds": 5804, "seasonCount": null, "communityRating": 6.798, "criticRating": 80, "genres": ["Drama", "Romance"], "overview": "Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.", "poster": "assets/movie-vicky-cristina-barcelona-2008-poster.jpg", "backdrop": "assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:34.0000000Z", "lastPlayedAt": "2026-08-11T20:12:57.3064089Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5038, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 74, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 283286, "tmdb": 6.797, "tmdbVotes": 4164}}], "shows": [{"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "tv-back-to-life-2019", "title": "Back to Life", "year": 2019, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 2, "communityRating": 7.367, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.", "poster": "assets/tv-back-to-life-2019-poster.jpg", "backdrop": "assets/tv-back-to-life-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0688758Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 88510, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 65}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-call-the-midwife-2012", "title": "Call the Midwife", "year": 2012, "type": "tv", "runtimeSeconds": 3120, "seasonCount": 15, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.", "poster": "assets/tv-call-the-midwife-2012-poster.jpg", "backdrop": "assets/tv-call-the-midwife-2012-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0377216Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 39793, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.577, "tmdbVotes": 267}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-desperate-housewives-2004", "title": "Desperate Housewives", "year": 2004, "type": "tv", "runtimeSeconds": 2520, "seasonCount": 8, "communityRating": 7.896, "criticRating": null, "genres": ["Mystery", "Drama", "Comedy"], "overview": "Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026", "poster": "assets/tv-desperate-housewives-2004-poster.jpg", "backdrop": "assets/tv-desperate-housewives-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T20:56:40.3505596Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 693, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.896, "tmdbVotes": 2171}}, {"id": "tv-the-diplomat-2023", "title": "The Diplomat", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.569, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.", "poster": "assets/tv-the-diplomat-2023-poster.jpg", "backdrop": "assets/tv-the-diplomat-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640348Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 203857, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.652, "tmdbVotes": 521}}, {"id": "tv-the-durrells-2016", "title": "The Durrells", "year": 2016, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 4, "communityRating": 7.402, "criticRating": null, "genres": ["Drama", "Comedy", "Family"], "overview": "In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026", "poster": "assets/tv-the-durrells-2016-poster.jpg", "backdrop": "assets/tv-the-durrells-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0619436Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 66134, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 127}}, {"id": "tv-etoile-2025", "title": "\u00c9toile", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.197, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.", "poster": "assets/tv-etoile-2025-poster.jpg", "backdrop": "assets/tv-etoile-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601383Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225383, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 40}}, {"id": "tv-gilmore-girls-2000", "title": "Gilmore Girls", "year": 2000, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 7, "communityRating": 7.893, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.", "poster": "assets/tv-gilmore-girls-2000-poster.jpg", "backdrop": "assets/tv-gilmore-girls-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0631035Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4586, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 1339}}, {"id": "tv-ginny-georgia-2021", "title": "Ginny & Georgia", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 8.026, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.", "poster": "assets/tv-ginny-georgia-2021-poster.jpg", "backdrop": "assets/tv-ginny-georgia-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0694828Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117581, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 1840}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-high-potential-2024", "title": "High Potential", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.201, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.", "poster": "assets/tv-high-potential-2024-poster.jpg", "backdrop": "assets/tv-high-potential-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0686091Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 226637, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.3, "tmdbVotes": 707}}, {"id": "tv-how-to-get-to-heaven-from-belfast-2026", "title": "How to Get to Heaven from Belfast", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.027, "criticRating": null, "genres": ["Mystery", "Comedy", "Drama"], "overview": "Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.", "poster": "assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg", "backdrop": "assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0683192Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 233079, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.063, "tmdbVotes": 95}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-lost-flowers-of-alice-hart-2023", "title": "The Lost Flowers of Alice Hart", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.634, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026", "poster": "assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg", "backdrop": "assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0607236Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 125816, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.64, "tmdbVotes": 161}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-maximum-pleasure-guaranteed-2026", "title": "Maximum Pleasure Guaranteed", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.676, "criticRating": null, "genres": ["Comedy"], "overview": "A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.", "poster": "assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg", "backdrop": "assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-07T21:04:02.8277793Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 285404, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.799, "tmdbVotes": 92}}, {"id": "tv-minx-2022", "title": "Minx", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.833, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.", "poster": "assets/tv-minx-2022-poster.jpg", "backdrop": "assets/tv-minx-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0653613Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 118303, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 123}}, {"id": "tv-offspring-2010", "title": "Offspring", "year": 2010, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 7, "communityRating": 6.9, "criticRating": null, "genres": ["Drama"], "overview": "An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.", "poster": "assets/tv-offspring-2010-poster.jpg", "backdrop": "assets/tv-offspring-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0664581Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 33155, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 27}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-outlander-blood-of-my-blood-2025", "title": "Outlander: Blood of My Blood", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.542, "criticRating": null, "genres": ["Drama"], "overview": "From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026", "poster": "assets/tv-outlander-blood-of-my-blood-2025-poster.jpg", "backdrop": "assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0691373Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 207484, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.738, "tmdbVotes": 124}}, {"id": "tv-rivals-2024", "title": "Rivals", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.44, "criticRating": null, "genres": ["Drama"], "overview": "It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026", "poster": "assets/tv-rivals-2024-poster.jpg", "backdrop": "assets/tv-rivals-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645815Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 208921, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 93}}, {"id": "tv-rooster-2026", "title": "Rooster", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.431, "criticRating": null, "genres": ["Comedy"], "overview": "On a college campus, an author navigates a complicated relationship with his daughter.", "poster": "assets/tv-rooster-2026-poster.jpg", "backdrop": "assets/tv-rooster-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.063389Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 254953, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.423, "tmdbVotes": 144}}, {"id": "tv-sex-and-the-city-1998", "title": "Sex and the City", "year": 1998, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.365, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026", "poster": "assets/tv-sex-and-the-city-1998-poster.jpg", "backdrop": "assets/tv-sex-and-the-city-1998-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0679324Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 105, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.351, "tmdbVotes": 2220}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-sex-and-the-country-2001-s4e9", "title": "Sex and the Country", "year": 2001, "type": "episode", "runtimeSeconds": 1751, "seasonCount": null, "communityRating": 5.9, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is dragged to Aidan's country home in Suffern, New York. Charlotte visits the MacDougal \"compound\" in Connecticut. Miranda yells at Steve when he tells her he has testicular cancer but has no clue about his\u2026", "poster": "assets/episode-sex-and-the-country-2001-s4e9-poster.jpg", "backdrop": "assets/episode-sex-and-the-country-2001-s4e9-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 96.08, "progress": 0.0549, "addedAt": "2026-07-17T10:53:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 9, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-what-more-do-i-need-2008-s5e7", "title": "What More Do I Need?", "year": 2008, "type": "episode", "runtimeSeconds": 2521, "seasonCount": null, "communityRating": 8, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Lynette and Tom uncover the unsettling truth about son Porter. Gabrielle suspects Carlos' best client, Virginia, has ulterior motives. Bree's moment of weakness could bring her great embarrassment and shame.\u2026", "poster": "assets/episode-what-more-do-i-need-2008-s5e7-poster.jpg", "backdrop": "assets/episode-what-more-do-i-need-2008-s5e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-29T14:37:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Desperate Housewives", "seasonNumber": 5, "episodeNumber": 7, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}, {"id": "episode-new-day-2021-s1e2", "title": "New Day", "year": 2021, "type": "episode", "runtimeSeconds": 3445, "seasonCount": null, "communityRating": 7.143, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "A cautiously optimistic Belinda walks a fine line with Tanya, while Armond brainstorms ways to deal with Shane. After her husband belittles her work, Rachel approaches Nicole for advice. Paula and Olivia lose track\u2026", "poster": "assets/episode-new-day-2021-s1e2-poster.jpg", "backdrop": "assets/episode-new-day-2021-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T22:18:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The White Lotus", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-uncomfortability-2010-s1e2", "title": "The Uncomfortability", "year": 2010, "type": "episode", "runtimeSeconds": 2687, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Nina's insecurities are revealed when a patient poses an awkward request. Meanwhile Geraldine decides to try online dating, and Jimmy is an unexpected saviour to Cherie and Darcy.", "poster": "assets/episode-the-uncomfortability-2010-s1e2-poster.jpg", "backdrop": "assets/episode-the-uncomfortability-2010-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:47:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Offspring", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-2-2016-s1e2", "title": "Episode 2", "year": 2016, "type": "episode", "runtimeSeconds": 2766, "seasonCount": null, "communityRating": 8.3, "criticRating": null, "genres": ["Biography", "Comedy", "Drama"], "overview": "Larry falls dangerously ill, and it falls to Theo and his neighbour Sven to put their rudimentary medical skills to the task of saving his life. The family's money worries continue, but Spiros brings them good news\u2026", "poster": "assets/episode-episode-2-2016-s1e2-poster.jpg", "backdrop": "assets/episode-episode-2-2016-s1e2-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-31T07:29:17.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Durrells", "seasonNumber": 1, "episodeNumber": 2, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-remembrance-day-2026-s2e1", "title": "Remembrance Day", "year": 2026, "type": "episode", "runtimeSeconds": 3822, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Fantasy", "Romance"], "overview": "The fiery crosses have been lit across the Highlands; lovers must part, as the call to arms brings friends and enemies home and the Jacobite Rebellion begins in earnest.", "poster": "assets/episode-remembrance-day-2026-s2e1-poster.jpg", "backdrop": "assets/episode-remembrance-day-2026-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-19T03:50:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Outlander: Blood of My Blood", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-one-of-us-2024-s1e7", "title": "One of Us", "year": 2024, "type": "episode", "runtimeSeconds": 2555, "seasonCount": null, "communityRating": 7.7, "criticRating": null, "genres": [], "overview": "Morgan agrees to show Ava around the LAPD, but things quickly take a dangerous turn when the precinct is held hostage by the friends of a recently convicted man. Can Morgan and the detectives outsmart their captors\u2026", "poster": "assets/episode-one-of-us-2024-s1e7-poster.jpg", "backdrop": "assets/episode-one-of-us-2024-s1e7-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:57:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "High Potential", "seasonNumber": 1, "episodeNumber": 7, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-breakup-part-2-2001-s1e17", "title": "The Breakup, Part 2", "year": 2001, "type": "episode", "runtimeSeconds": 2669, "seasonCount": null, "communityRating": 7.4, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "After recent events, Rory keeps busy, refusing to give in to self-pity. Meanwhile, Stars Hollow's residents rally around her, much to her dismay.", "poster": "assets/episode-the-breakup-part-2-2001-s1e17-poster.jpg", "backdrop": "assets/episode-the-breakup-part-2-2001-s1e17-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T11:28:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Gilmore Girls", "seasonNumber": 1, "episodeNumber": 17, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-i-heart-ny-2002-s4e18", "title": "I Heart NY", "year": 2002, "type": "episode", "runtimeSeconds": 1905, "seasonCount": null, "communityRating": 6.667, "criticRating": null, "genres": ["Comedy", "Drama", "Romance"], "overview": "Carrie is shocked to find out that Big is moving to Napa, California and plans to give him a New York send-off. Charlotte begins dating another recent divorc\u00e9. Miranda prepares for the new baby as her delivery date\u2026", "poster": "assets/episode-i-heart-ny-2002-s4e18-poster.jpg", "backdrop": "assets/episode-i-heart-ny-2002-s4e18-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:35:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Sex and the City", "seasonNumber": 4, "episodeNumber": 18, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.431, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:31:47.4592756Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.398, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2163301Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-big-boys-2022", "title": "Big Boys", "year": 2022, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026", "poster": "assets/tv-big-boys-2022-poster.jpg", "backdrop": "assets/tv-big-boys-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:25:58.2156837Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 202851, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 49}}, {"id": "tv-all-creatures-great-small-2020", "title": "All Creatures Great & Small", "year": 2020, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 7, "communityRating": 8.2, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.", "poster": "assets/tv-all-creatures-great-small-2020-poster.jpg", "backdrop": "assets/tv-all-creatures-great-small-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T20:13:03.4266156Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 108255, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.2, "tmdbVotes": 168}}, {"id": "movie-the-killing-of-a-sacred-deer-2017", "title": "The Killing of a Sacred Deer", "year": 2017, "type": "movie", "runtimeSeconds": 7262, "seasonCount": null, "communityRating": 7.003, "criticRating": 79, "genres": ["Drama", "Thriller", "Mystery"], "overview": "Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026", "poster": "assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg", "backdrop": "assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:49:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 399057, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 219488, "tmdb": 7.001, "tmdbVotes": 4640}}, {"id": "tv-derry-girls-2018", "title": "Derry Girls", "year": 2018, "type": "tv", "runtimeSeconds": 1380, "seasonCount": 3, "communityRating": 8.144, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.", "poster": "assets/tv-derry-girls-2018-poster.jpg", "backdrop": "assets/tv-derry-girls-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-02T20:01:55.8955537Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 76148, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.138, "tmdbVotes": 454}}, {"id": "tv-so-you-think-you-can-dance-2008", "title": "So You Think You Can Dance", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 9.8, "criticRating": null, "genres": ["Family", "Reality"], "overview": "So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026", "poster": "assets/tv-so-you-think-you-can-dance-2008-poster.jpg", "backdrop": "assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-23T11:11:35.4124418Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 40231, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 9.8, "tmdbVotes": 4}}, {"id": "tv-hellcats-2010", "title": "Hellcats", "year": 2010, "type": "tv", "runtimeSeconds": 2460, "seasonCount": 1, "communityRating": 7.5, "criticRating": null, "genres": ["Drama"], "overview": "Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026", "poster": "assets/tv-hellcats-2010-poster.jpg", "backdrop": "assets/tv-hellcats-2010-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T20:39:02.7719318Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 32504, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 116}}, {"id": "tv-skymed-2022", "title": "SkyMed", "year": 2022, "type": "tv", "runtimeSeconds": 2640, "seasonCount": 4, "communityRating": 6.665, "criticRating": null, "genres": ["Drama"], "overview": "Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026", "poster": "assets/tv-skymed-2022-poster.jpg", "backdrop": "assets/tv-skymed-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T19:44:29.2431354Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 133908, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.699, "tmdbVotes": 98}}, {"id": "tv-love-is-blind-2020", "title": "Love Is Blind", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 11, "communityRating": 6.261, "criticRating": null, "genres": ["Reality"], "overview": "Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.", "poster": "assets/tv-love-is-blind-2020-poster.jpg", "backdrop": "assets/tv-love-is-blind-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-22T17:11:08.2447226Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 99353, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.233, "tmdbVotes": 118}}, {"id": "tv-the-hunting-wives-2025", "title": "The Hunting Wives", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 6.932, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.", "poster": "assets/tv-the-hunting-wives-2025-poster.jpg", "backdrop": "assets/tv-the-hunting-wives-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:46.3227892Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 244447, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.964, "tmdbVotes": 112}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.597, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T02:45:52.8742674Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}], "discover": [{"id": "movie-across-the-hall-2009", "title": "Across the Hall", "year": 2009, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Mystery"], "overview": "Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.", "poster": "assets/movie-across-the-hall-2009-poster.jpg", "backdrop": "assets/movie-across-the-hall-2009-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 21469, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 39, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5130, "tmdb": 6, "tmdbVotes": 129}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-adulterers-2016", "title": "Adulterers", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Drama"], "overview": "On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026", "poster": "assets/movie-adulterers-2016-poster.jpg", "backdrop": "assets/movie-adulterers-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 376004, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 5.1, "imdbVotes": 2144, "tmdb": 6.2, "tmdbVotes": 116}}, {"id": "movie-alone-with-her-2006", "title": "Alone With Her", "year": 2006, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026", "poster": "assets/movie-alone-with-her-2006-poster.jpg", "backdrop": "assets/movie-alone-with-her-2006-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18206, "ratings": {"rtCritics": 70, "rtCriticsLabel": "Fresh", "rtAudience": 46, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 4359, "tmdb": 5.9, "tmdbVotes": 94}}, {"id": "movie-awake-2007", "title": "Awake", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026", "poster": "assets/movie-awake-2007-poster.jpg", "backdrop": "assets/movie-awake-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13483, "ratings": {"rtCritics": 23, "rtCriticsLabel": "Rotten", "rtAudience": 55, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 89389, "tmdb": 6.586, "tmdbVotes": 1649}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-butterfly-on-a-wheel-2007", "title": "Butterfly on a Wheel", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Thriller"], "overview": "A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.", "poster": "assets/movie-butterfly-on-a-wheel-2007-poster.jpg", "backdrop": "assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13250, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 35409, "tmdb": 6.457, "tmdbVotes": 595}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "movie-crime-of-passion-1956", "title": "Crime of Passion", "year": 1956, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Thriller"], "overview": "Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026", "poster": "assets/movie-crime-of-passion-1956-poster.jpg", "backdrop": "assets/movie-crime-of-passion-1956-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1920, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": 34, "rtAudienceLabel": "Spilled", "imdb": 6.4, "imdbVotes": 3750, "tmdb": 6.1, "tmdbVotes": 57}}, {"id": "movie-deep-water-2022", "title": "Deep Water", "year": 2022, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.", "poster": "assets/movie-deep-water-2022-poster.jpg", "backdrop": "assets/movie-deep-water-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 619979, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 25, "rtAudienceLabel": "Spilled", "imdb": 5.5, "imdbVotes": 74098, "tmdb": 5.834, "tmdbVotes": 1755}}, {"id": "movie-derailed-2005", "title": "Derailed", "year": 2005, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.", "poster": "assets/movie-derailed-2005-poster.jpg", "backdrop": "assets/movie-derailed-2005-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 8999, "ratings": {"rtCritics": 20, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.6, "imdbVotes": 79563, "tmdb": 6.405, "tmdbVotes": 1193}}, {"id": "movie-die-my-love-2025", "title": "Die My Love", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026", "poster": "assets/movie-die-my-love-2025-poster.jpg", "backdrop": "assets/movie-die-my-love-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1033148, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 50664, "tmdb": 6.1, "tmdbVotes": 683}}, {"id": "movie-dream-house-2011", "title": "Dream House", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Drama", "Thriller"], "overview": "Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026", "poster": "assets/movie-dream-house-2011-poster.jpg", "backdrop": "assets/movie-dream-house-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69668, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 71386, "tmdb": 6.212, "tmdbVotes": 1480}}, {"id": "movie-dream-lover-1994", "title": "Dream Lover", "year": 1994, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery", "Thriller"], "overview": "Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026", "poster": "assets/movie-dream-lover-1994-poster.jpg", "backdrop": "assets/movie-dream-lover-1994-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 18190, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 38, "rtAudienceLabel": "Spilled", "imdb": 6.2, "imdbVotes": 7865, "tmdb": 6.844, "tmdbVotes": 167}}, {"id": "movie-drop-2025", "title": "Drop", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Thriller"], "overview": "Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026", "poster": "assets/movie-drop-2025-poster.jpg", "backdrop": "assets/movie-drop-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1249213, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 79, "rtAudienceLabel": "Upright", "imdb": 6.1, "imdbVotes": 61018, "tmdb": 6.275, "tmdbVotes": 1023}}, {"id": "movie-eastern-promises-2007", "title": "Eastern Promises", "year": 2007, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "Crime", "Mystery"], "overview": "A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.", "poster": "assets/movie-eastern-promises-2007-poster.jpg", "backdrop": "assets/movie-eastern-promises-2007-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 2252, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 281015, "tmdb": 7.349, "tmdbVotes": 3900}}, {"id": "movie-faces-1968", "title": "Faces", "year": 1968, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026", "poster": "assets/movie-faces-1968-poster.jpg", "backdrop": "assets/movie-faces-1968-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 753, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 12950, "tmdb": 7.093, "tmdbVotes": 227}}, {"id": "movie-fatale-2020", "title": "Fatale", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026", "poster": "assets/movie-fatale-2020-poster.jpg", "backdrop": "assets/movie-fatale-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 549514, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 5.5, "imdbVotes": 16392, "tmdb": 6, "tmdbVotes": 401}}, {"id": "tv-56-days-2026", "title": "56 Days", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.", "poster": "assets/tv-56-days-2026-poster.jpg", "backdrop": "assets/tv-56-days-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 241372, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.388, "tmdbVotes": 245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-absolutely-fabulous-1992", "title": "Absolutely Fabulous", "year": 1992, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026", "poster": "assets/tv-absolutely-fabulous-1992-poster.jpg", "backdrop": "assets/tv-absolutely-fabulous-1992-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 4493, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 198}}, {"id": "tv-the-afterparty-2022", "title": "The Afterparty", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Mystery", "Comedy", "Crime"], "overview": "Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.", "poster": "assets/tv-the-afterparty-2022-poster.jpg", "backdrop": "assets/tv-the-afterparty-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 106454, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.071, "tmdbVotes": 245}}, {"id": "tv-agatha-christie-s-marple-2004", "title": "Agatha Christie's Marple", "year": 2004, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime", "Mystery"], "overview": "The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026", "poster": "assets/tv-agatha-christie-s-marple-2004-poster.jpg", "backdrop": "assets/tv-agatha-christie-s-marple-2004-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 6127, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 152}}, {"id": "tv-all-american-homecoming-2022", "title": "All American: Homecoming", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama"], "overview": "Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.", "poster": "assets/tv-all-american-homecoming-2022-poster.jpg", "backdrop": "assets/tv-all-american-homecoming-2022-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 153723, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 70}}, {"id": "tv-and-just-like-that-2021", "title": "And Just Like That\u2026", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026", "poster": "assets/tv-and-just-like-that-2021-poster.jpg", "backdrop": "assets/tv-and-just-like-that-2021-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 116450, "ratings": {"rtCritics": 52, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 556}}, {"id": "tv-anne-with-an-e-2017", "title": "Anne with an E", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Family"], "overview": "A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026", "poster": "assets/tv-anne-with-an-e-2017-poster.jpg", "backdrop": "assets/tv-anne-with-an-e-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 70785, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.7, "tmdbVotes": 5019}}, {"id": "tv-awkward-2011", "title": "Awkward.", "year": 2011, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026", "poster": "assets/tv-awkward-2011-poster.jpg", "backdrop": "assets/tv-awkward-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 39859, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.4, "tmdbVotes": 427}}, {"id": "tv-the-baby-sitters-club-2020", "title": "The Baby-Sitters Club", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Kids", "Drama"], "overview": "In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.", "poster": "assets/tv-the-baby-sitters-club-2020-poster.jpg", "backdrop": "assets/tv-the-baby-sitters-club-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100412, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.9, "tmdbVotes": 506}}, {"id": "tv-ballers-2015", "title": "Ballers", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026", "poster": "assets/tv-ballers-2015-poster.jpg", "backdrop": "assets/tv-ballers-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62704, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.809, "tmdbVotes": 820}}, {"id": "tv-baretta-1975", "title": "Baretta", "year": 1975, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026", "poster": "assets/tv-baretta-1975-poster.jpg", "backdrop": "assets/tv-baretta-1975-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 3216, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 43}}, {"id": "tv-based-on-a-true-story-2023", "title": "Based on a True Story", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.", "poster": "assets/tv-based-on-a-true-story-2023-poster.jpg", "backdrop": "assets/tv-based-on-a-true-story-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 197373, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 160}}, {"id": "tv-the-beast-in-me-2025", "title": "The Beast in Me", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.", "poster": "assets/tv-the-beast-in-me-2025-poster.jpg", "backdrop": "assets/tv-the-beast-in-me-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 250504, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.072, "tmdbVotes": 334}}, {"id": "tv-big-city-greens-2018", "title": "Big City Greens", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Comedy"], "overview": "The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.", "poster": "assets/tv-big-city-greens-2018-poster.jpg", "backdrop": "assets/tv-big-city-greens-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 80587, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.555, "tmdbVotes": 82}}, {"id": "tv-the-bold-type-2017", "title": "The Bold Type", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.", "poster": "assets/tv-the-bold-type-2017-poster.jpg", "backdrop": "assets/tv-the-bold-type-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 69630, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 204}}, {"id": "tv-brassic-2019", "title": "Brassic", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama", "Crime"], "overview": "A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026", "poster": "assets/tv-brassic-2019-poster.jpg", "backdrop": "assets/tv-brassic-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 92621, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.336, "tmdbVotes": 263}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}, {"key": "shared", "name": "Shared", "initial": "S", "color": "#9be38f", "destinations": {"Home": {"kind": "home"}, "Movies": {"kind": "library", "collection": "movies"}, "Shows": {"kind": "library", "collection": "shows"}, "Discover": {"kind": "discover", "collection": "discover"}}, "movies": [{"id": "movie-american-gangster-2007", "title": "American Gangster", "year": 2007, "type": "movie", "runtimeSeconds": 10546, "seasonCount": null, "communityRating": 7.595, "criticRating": 81, "genres": ["Drama", "Crime"], "overview": "Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026", "poster": "assets/movie-american-gangster-2007-poster.jpg", "backdrop": "assets/movie-american-gangster-2007-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-23T10:22:36.0000000Z", "lastPlayedAt": "2026-08-30T21:05:39.7952451Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4982, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 495656, "tmdb": 7.599, "tmdbVotes": 6350}}, {"id": "movie-annie-hall-1977", "title": "Annie Hall", "year": 1977, "type": "movie", "runtimeSeconds": 5604, "seasonCount": null, "communityRating": 7.712, "criticRating": 97, "genres": ["Comedy", "Drama", "Romance"], "overview": "New York comedian Alvy Singer falls in love with the ditsy Annie Hall.", "poster": "assets/movie-annie-hall-1977-poster.jpg", "backdrop": "assets/movie-annie-hall-1977-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:23.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 703, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 293349, "tmdb": 7.71, "tmdbVotes": 4391}}, {"id": "movie-apocalypse-now-1979", "title": "Apocalypse Now", "year": 1979, "type": "movie", "runtimeSeconds": 8835, "seasonCount": null, "communityRating": 8.261, "criticRating": 91, "genres": ["Drama", "War"], "overview": "At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026", "poster": "assets/movie-apocalypse-now-1979-poster.jpg", "backdrop": "assets/movie-apocalypse-now-1979-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 28, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.4, "imdbVotes": 781424, "tmdb": 8.3, "tmdbVotes": 9526}}, {"id": "movie-arrival-2016", "title": "Arrival", "year": 2016, "type": "movie", "runtimeSeconds": 6967, "seasonCount": null, "communityRating": 7.636, "criticRating": 94, "genres": ["Drama", "Science Fiction", "Mystery"], "overview": "Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.", "poster": "assets/movie-arrival-2016-poster.jpg", "backdrop": "assets/movie-arrival-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-14T19:54:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 329865, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 883621, "tmdb": 7.638, "tmdbVotes": 20125}}, {"id": "movie-avatar-aang-the-last-airbender-2026", "title": "Avatar Aang: The Last Airbender", "year": 2026, "type": "movie", "runtimeSeconds": 5930, "seasonCount": null, "communityRating": 9.147, "criticRating": 90, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026", "poster": "assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg", "backdrop": "assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-25T09:53:09.0000000Z", "lastPlayedAt": "2026-08-02T19:22:33.6155383Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 980431, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 30792, "tmdb": 9, "tmdbVotes": 1417}}, {"id": "movie-bohemian-rhapsody-2018", "title": "Bohemian Rhapsody", "year": 2018, "type": "movie", "runtimeSeconds": 8077, "seasonCount": null, "communityRating": 7.95, "criticRating": 60, "genres": ["Music", "Drama"], "overview": "Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026", "poster": "assets/movie-bohemian-rhapsody-2018-poster.jpg", "backdrop": "assets/movie-bohemian-rhapsody-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 424694, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 655894, "tmdb": 7.947, "tmdbVotes": 18196}}, {"id": "movie-the-boy-and-the-heron-2023", "title": "The Boy and the Heron", "year": 2023, "type": "movie", "runtimeSeconds": 7437, "seasonCount": null, "communityRating": 7.4, "criticRating": 96, "genres": ["Animation", "Fantasy", "Drama"], "overview": "While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026", "poster": "assets/movie-the-boy-and-the-heron-2023-poster.jpg", "backdrop": "assets/movie-the-boy-and-the-heron-2023-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 508883, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 152169, "tmdb": 7.387, "tmdbVotes": 2893}}, {"id": "movie-braveheart-1995", "title": "Braveheart", "year": 1995, "type": "movie", "runtimeSeconds": 10665, "seasonCount": null, "communityRating": 7.945, "criticRating": 74, "genres": ["Drama", "History", "War"], "overview": "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026", "poster": "assets/movie-braveheart-1995-poster.jpg", "backdrop": "assets/movie-braveheart-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:24:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 74, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 1174990, "tmdb": 7.947, "tmdbVotes": 11584}}, {"id": "movie-the-breakfast-club-1985", "title": "The Breakfast Club", "year": 1985, "type": "movie", "runtimeSeconds": 5833, "seasonCount": null, "communityRating": 7.7, "criticRating": 87, "genres": ["Comedy", "Drama"], "overview": "Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026", "poster": "assets/movie-the-breakfast-club-1985-poster.jpg", "backdrop": "assets/movie-the-breakfast-club-1985-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2108, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 478743, "tmdb": 7.713, "tmdbVotes": 8815}}, {"id": "movie-castle-in-the-sky-1986", "title": "Castle in the Sky", "year": 1986, "type": "movie", "runtimeSeconds": 7474, "seasonCount": null, "communityRating": 8, "criticRating": 97, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.", "poster": "assets/movie-castle-in-the-sky-1986-poster.jpg", "backdrop": "assets/movie-castle-in-the-sky-1986-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 10515, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 201545, "tmdb": 7.972, "tmdbVotes": 4868}}, {"id": "movie-citizen-kane-1941", "title": "Citizen Kane", "year": 1941, "type": "movie", "runtimeSeconds": 7173, "seasonCount": null, "communityRating": 7.966, "criticRating": 99, "genres": ["Mystery", "Drama"], "overview": "Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026", "poster": "assets/movie-citizen-kane-1941-poster.jpg", "backdrop": "assets/movie-citizen-kane-1941-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:16.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 15, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 498321, "tmdb": 7.962, "tmdbVotes": 6324}}, {"id": "movie-conclave-2024", "title": "Conclave", "year": 2024, "type": "movie", "runtimeSeconds": 7230, "seasonCount": null, "communityRating": 7.19, "criticRating": 93, "genres": ["Drama", "Thriller"], "overview": "After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026", "poster": "assets/movie-conclave-2024-poster.jpg", "backdrop": "assets/movie-conclave-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:41.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 974576, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 290498, "tmdb": 7.19, "tmdbVotes": 3744}}, {"id": "movie-the-dry-2021", "title": "The Dry", "year": 2021, "type": "movie", "runtimeSeconds": 7037, "seasonCount": null, "communityRating": 6.779, "criticRating": null, "genres": ["Crime", "Thriller", "Mystery"], "overview": "Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.", "poster": "assets/movie-the-dry-2021-poster.jpg", "backdrop": "assets/movie-the-dry-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 567797, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 41935, "tmdb": 6.8, "tmdbVotes": 892}}, {"id": "movie-easy-a-2010", "title": "Easy A", "year": 2010, "type": "movie", "runtimeSeconds": 5545, "seasonCount": null, "communityRating": 6.8, "criticRating": 85, "genres": ["Comedy"], "overview": "Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026", "poster": "assets/movie-easy-a-2010-poster.jpg", "backdrop": "assets/movie-easy-a-2010-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-17T19:59:04.0000000Z", "lastPlayedAt": "2026-10-17T20:02:51.1083337Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 37735, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Certified Fresh", "rtAudience": 77, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 448780, "tmdb": 6.8, "tmdbVotes": 7619}}, {"id": "movie-el-camino-a-breaking-bad-movie-2019", "title": "El Camino: A Breaking Bad Movie", "year": 2019, "type": "movie", "runtimeSeconds": 7362, "seasonCount": null, "communityRating": 7.055, "criticRating": 92, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.", "poster": "assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg", "backdrop": "assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-21T23:31:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 559969, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 354488, "tmdb": 7.056, "tmdbVotes": 5950}}, {"id": "movie-emancipation-2022", "title": "Emancipation", "year": 2022, "type": "movie", "runtimeSeconds": 7949, "seasonCount": null, "communityRating": 7.7, "criticRating": 45, "genres": ["Drama", "War", "History"], "overview": "Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026", "poster": "assets/movie-emancipation-2022-poster.jpg", "backdrop": "assets/movie-emancipation-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:37:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 715931, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 45, "rtCriticsLabel": "Rotten", "rtAudience": 58, "rtAudienceLabel": "Spilled", "imdb": 6.3, "imdbVotes": 32079, "tmdb": 7.744, "tmdbVotes": 1319}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "movie-force-of-nature-the-dry-2-2024", "title": "Force of Nature: The Dry 2", "year": 2024, "type": "movie", "runtimeSeconds": 6753, "seasonCount": null, "communityRating": 6.3, "criticRating": 66, "genres": ["Mystery", "Thriller"], "overview": "When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026", "poster": "assets/movie-force-of-nature-the-dry-2-2024-poster.jpg", "backdrop": "assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 832262, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 43, "rtAudienceLabel": "Spilled", "imdb": 5.9, "imdbVotes": 10343, "tmdb": 6.269, "tmdbVotes": 305}}, {"id": "movie-free-state-of-jones-2016", "title": "Free State of Jones", "year": 2016, "type": "movie", "runtimeSeconds": 8376, "seasonCount": null, "communityRating": 6.79, "criticRating": 48, "genres": ["Action", "Drama", "History"], "overview": "In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026", "poster": "assets/movie-free-state-of-jones-2016-poster.jpg", "backdrop": "assets/movie-free-state-of-jones-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-11T17:56:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 316152, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 68931, "tmdb": 6.788, "tmdbVotes": 1730}}, {"id": "movie-from-up-on-poppy-hill-2011", "title": "From Up on Poppy Hill", "year": 2011, "type": "movie", "runtimeSeconds": 5482, "seasonCount": null, "communityRating": 7.5, "criticRating": 87, "genres": ["Animation", "Drama"], "overview": "Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026", "poster": "assets/movie-from-up-on-poppy-hill-2011-poster.jpg", "backdrop": "assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T14:41:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 83389, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 60769, "tmdb": 7.509, "tmdbVotes": 2026}}, {"id": "movie-ghost-in-the-shell-1995", "title": "Ghost in the Shell", "year": 1995, "type": "movie", "runtimeSeconds": 4959, "seasonCount": null, "communityRating": 7.929, "criticRating": 95, "genres": ["Action", "Animation", "Science Fiction"], "overview": "In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026", "poster": "assets/movie-ghost-in-the-shell-1995-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-1995-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:57.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9323, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 178061, "tmdb": 7.928, "tmdbVotes": 4130}}, {"id": "movie-ghost-in-the-shell-2-innocence-2004", "title": "Ghost in the Shell 2: Innocence", "year": 2004, "type": "movie", "runtimeSeconds": 5950, "seasonCount": null, "communityRating": 7.352, "criticRating": 65, "genres": ["Animation", "Drama", "Science Fiction"], "overview": "Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026", "poster": "assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg", "backdrop": "assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12140, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 65, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 43280, "tmdb": 7.356, "tmdbVotes": 1049}}, {"id": "movie-the-godfather-1972", "title": "The Godfather", "year": 1972, "type": "movie", "runtimeSeconds": 10603, "seasonCount": null, "communityRating": 8.687, "criticRating": 97, "genres": ["Drama", "Crime"], "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026", "poster": "assets/movie-the-godfather-1972-poster.jpg", "backdrop": "assets/movie-the-godfather-1972-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:12.0000000Z", "lastPlayedAt": "2026-09-18T19:24:44.9855867Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 238, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 9.2, "imdbVotes": 2262024, "tmdb": 8.688, "tmdbVotes": 23907}}, {"id": "movie-the-godfather-part-ii-1974", "title": "The Godfather Part II", "year": 1974, "type": "movie", "runtimeSeconds": 12031, "seasonCount": null, "communityRating": 8.573, "criticRating": 96, "genres": ["Drama", "Crime"], "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026", "poster": "assets/movie-the-godfather-part-ii-1974-poster.jpg", "backdrop": "assets/movie-the-godfather-part-ii-1974-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:18.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 240, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 9, "imdbVotes": 1521308, "tmdb": 8.571, "tmdbVotes": 14559}}, {"id": "movie-the-godfather-part-iii-1990", "title": "The Godfather Part III", "year": 1990, "type": "movie", "runtimeSeconds": 9476, "seasonCount": null, "communityRating": 7.416, "criticRating": 67, "genres": ["Crime", "Drama", "Thriller"], "overview": "In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.", "poster": "assets/movie-the-godfather-part-iii-1990-poster.jpg", "backdrop": "assets/movie-the-godfather-part-iii-1990-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:29.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 242, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 68, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 462448, "tmdb": 7.413, "tmdbVotes": 7159}}, {"id": "movie-gosford-park-2001", "title": "Gosford Park", "year": 2001, "type": "movie", "runtimeSeconds": 8247, "seasonCount": null, "communityRating": 6.815, "criticRating": 87, "genres": ["Drama", "Mystery", "Thriller"], "overview": "In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.", "poster": "assets/movie-gosford-park-2001-poster.jpg", "backdrop": "assets/movie-gosford-park-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 5279, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 78, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 103883, "tmdb": 6.822, "tmdbVotes": 1317}}, {"id": "movie-grave-of-the-fireflies-1988", "title": "Grave of the Fireflies", "year": 1988, "type": "movie", "runtimeSeconds": 5313, "seasonCount": null, "communityRating": 8.45, "criticRating": 100, "genres": ["Animation", "Drama", "War"], "overview": "In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026", "poster": "assets/movie-grave-of-the-fireflies-1988-poster.jpg", "backdrop": "assets/movie-grave-of-the-fireflies-1988-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:14.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12477, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 8.5, "imdbVotes": 399802, "tmdb": 8.45, "tmdbVotes": 6938}}, {"id": "movie-the-great-gatsby-2013", "title": "The Great Gatsby", "year": 2013, "type": "movie", "runtimeSeconds": 8525, "seasonCount": null, "communityRating": 7.346, "criticRating": 49, "genres": ["Drama", "Romance"], "overview": "Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026", "poster": "assets/movie-the-great-gatsby-2013-poster.jpg", "backdrop": "assets/movie-the-great-gatsby-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:50.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64682, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 48, "rtCriticsLabel": "Rotten", "rtAudience": 67, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 651882, "tmdb": 7.346, "tmdbVotes": 13290}}, {"id": "movie-the-hours-2002", "title": "The Hours", "year": 2002, "type": "movie", "runtimeSeconds": 6887, "seasonCount": null, "communityRating": 7.25, "criticRating": 79, "genres": ["Drama"], "overview": "The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026", "poster": "assets/movie-the-hours-2002-poster.jpg", "backdrop": "assets/movie-the-hours-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 590, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 148367, "tmdb": 7.249, "tmdbVotes": 1931}}, {"id": "movie-howl-s-moving-castle-2004", "title": "Howl's Moving Castle", "year": 2004, "type": "movie", "runtimeSeconds": 7149, "seasonCount": null, "communityRating": 8.4, "criticRating": 88, "genres": ["Fantasy", "Animation", "Adventure"], "overview": "Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.", "poster": "assets/movie-howl-s-moving-castle-2004-poster.jpg", "backdrop": "assets/movie-howl-s-moving-castle-2004-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:47.0000000Z", "lastPlayedAt": "2026-05-21T18:46:08.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4935, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 524520, "tmdb": 8.391, "tmdbVotes": 11642}}, {"id": "movie-in-the-heights-2021", "title": "In the Heights", "year": 2021, "type": "movie", "runtimeSeconds": 8579, "seasonCount": null, "communityRating": 7.111, "criticRating": null, "genres": ["Drama", "Romance"], "overview": "The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.", "poster": "assets/movie-in-the-heights-2021-poster.jpg", "backdrop": "assets/movie-in-the-heights-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:11.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 467909, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 56513, "tmdb": 7.104, "tmdbVotes": 826}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "movie-kiki-s-delivery-service-1989", "title": "Kiki's Delivery Service", "year": 1989, "type": "movie", "runtimeSeconds": 6183, "seasonCount": null, "communityRating": 7.839, "criticRating": null, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.", "poster": "assets/movie-kiki-s-delivery-service-1989-poster.jpg", "backdrop": "assets/movie-kiki-s-delivery-service-1989-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 16859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 192477, "tmdb": 7.839, "tmdbVotes": 4811}}, {"id": "movie-the-last-of-the-mohicans-1992", "title": "The Last of the Mohicans", "year": 1992, "type": "movie", "runtimeSeconds": 6874, "seasonCount": null, "communityRating": 7.388, "criticRating": 88, "genres": ["History", "War", "Drama"], "overview": "In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026", "poster": "assets/movie-the-last-of-the-mohicans-1992-poster.jpg", "backdrop": "assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:59:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9361, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 210791, "tmdb": 7.39, "tmdbVotes": 3593}}, {"id": "movie-the-last-samurai-2003", "title": "The Last Samurai", "year": 2003, "type": "movie", "runtimeSeconds": 9251, "seasonCount": null, "communityRating": 7.61, "criticRating": 65, "genres": ["Drama", "Action", "War"], "overview": "Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026", "poster": "assets/movie-the-last-samurai-2003-poster.jpg", "backdrop": "assets/movie-the-last-samurai-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:25:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 616, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 512982, "tmdb": 7.614, "tmdbVotes": 7887}}, {"id": "movie-lemony-snicket-s-a-series-of-unfortunate-events-2004", "title": "Lemony Snicket's A Series of Unfortunate Events", "year": 2004, "type": "movie", "runtimeSeconds": 6479, "seasonCount": null, "communityRating": 6.8, "criticRating": 72, "genres": ["Adventure", "Comedy", "Family"], "overview": "Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.", "poster": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg", "backdrop": "assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:09.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11774, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 233636, "tmdb": 6.82, "tmdbVotes": 5447}}, {"id": "movie-the-lighthouse-2019", "title": "The Lighthouse", "year": 2019, "type": "movie", "runtimeSeconds": 6549, "seasonCount": null, "communityRating": 7.463, "criticRating": 90, "genres": ["Drama", "Fantasy", "Thriller"], "overview": "Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.", "poster": "assets/movie-the-lighthouse-2019-poster.jpg", "backdrop": "assets/movie-the-lighthouse-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 503919, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 312344, "tmdb": 7.46, "tmdbVotes": 5895}}, {"id": "movie-locke-2014", "title": "Locke", "year": 2014, "type": "movie", "runtimeSeconds": 5090, "seasonCount": null, "communityRating": 6.888, "criticRating": 91, "genres": ["Drama", "Thriller"], "overview": "Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026", "poster": "assets/movie-locke-2014-poster.jpg", "backdrop": "assets/movie-locke-2014-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:38:48.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 171265, "tmdb": 6.889, "tmdbVotes": 3088}}, {"id": "movie-the-matrix-1999", "title": "The Matrix", "year": 1999, "type": "movie", "runtimeSeconds": 8179, "seasonCount": null, "communityRating": 8.259, "criticRating": 83, "genres": ["Action", "Science Fiction"], "overview": "Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.", "poster": "assets/movie-the-matrix-1999-poster.jpg", "backdrop": "assets/movie-the-matrix-1999-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:18:12.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 603, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 83, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 8.7, "imdbVotes": 2289808, "tmdb": 8.262, "tmdbVotes": 29153}}, {"id": "movie-the-matrix-reloaded-2003", "title": "The Matrix Reloaded", "year": 2003, "type": "movie", "runtimeSeconds": 8298, "seasonCount": null, "communityRating": 7.085, "criticRating": 74, "genres": ["Adventure", "Action", "Thriller"], "overview": "The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026", "poster": "assets/movie-the-matrix-reloaded-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-reloaded-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 604, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 678013, "tmdb": 7.087, "tmdbVotes": 12504}}, {"id": "movie-the-matrix-resurrections-2021", "title": "The Matrix Resurrections", "year": 2021, "type": "movie", "runtimeSeconds": 8875, "seasonCount": null, "communityRating": 6.303, "criticRating": 63, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.", "poster": "assets/movie-the-matrix-resurrections-2021-poster.jpg", "backdrop": "assets/movie-the-matrix-resurrections-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 624860, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 63, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 307780, "tmdb": 6.294, "tmdbVotes": 6842}}, {"id": "movie-the-matrix-revolutions-2003", "title": "The Matrix Revolutions", "year": 2003, "type": "movie", "runtimeSeconds": 7756, "seasonCount": null, "communityRating": 6.758, "criticRating": 33, "genres": ["Adventure", "Action", "Thriller"], "overview": "The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.", "poster": "assets/movie-the-matrix-revolutions-2003-poster.jpg", "backdrop": "assets/movie-the-matrix-revolutions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:25:47.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 605, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 34, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 581553, "tmdb": 6.762, "tmdbVotes": 11292}}, {"id": "movie-mercy-2026", "title": "Mercy", "year": 2026, "type": "movie", "runtimeSeconds": 5968, "seasonCount": null, "communityRating": 7.077, "criticRating": null, "genres": ["Science Fiction", "Thriller", "Crime"], "overview": "In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.", "poster": "assets/movie-mercy-2026-poster.jpg", "backdrop": "assets/movie-mercy-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1236153, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 24, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 83878, "tmdb": 7.054, "tmdbVotes": 1812}}, {"id": "movie-normal-2026", "title": "Normal", "year": 2026, "type": "movie", "runtimeSeconds": 5514, "seasonCount": null, "communityRating": 7.267, "criticRating": 77, "genres": ["Action", "Crime", "Thriller"], "overview": "An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.", "poster": "assets/movie-normal-2026-poster.jpg", "backdrop": "assets/movie-normal-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-21T08:33:18.0000000Z", "lastPlayedAt": "2026-09-24T23:07:52.1161581Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1242332, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 78, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.3, "imdbVotes": 37501, "tmdb": 7.241, "tmdbVotes": 786}}, {"id": "movie-outlaw-king-2018", "title": "Outlaw King", "year": 2018, "type": "movie", "runtimeSeconds": 7297, "seasonCount": null, "communityRating": 6.829, "criticRating": 63, "genres": ["Action", "History", "War"], "overview": "Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.", "poster": "assets/movie-outlaw-king-2018-poster.jpg", "backdrop": "assets/movie-outlaw-king-2018-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:32.0000000Z", "lastPlayedAt": "2026-07-26T22:35:35.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 454227, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 63, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 89876, "tmdb": 6.844, "tmdbVotes": 1780}}, {"id": "movie-panic-room-2002", "title": "Panic Room", "year": 2002, "type": "movie", "runtimeSeconds": 6717, "seasonCount": null, "communityRating": 6.794, "criticRating": 76, "genres": ["Crime", "Drama", "Thriller"], "overview": "Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026", "poster": "assets/movie-panic-room-2002-poster.jpg", "backdrop": "assets/movie-panic-room-2002-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-19T20:50:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4547, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 76, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 317468, "tmdb": 6.796, "tmdbVotes": 5653}}, {"id": "movie-the-patriot-2000", "title": "The Patriot", "year": 2000, "type": "movie", "runtimeSeconds": 9887, "seasonCount": null, "communityRating": 7.214, "criticRating": 61, "genres": ["Drama", "History", "War"], "overview": "After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026", "poster": "assets/movie-the-patriot-2000-poster.jpg", "backdrop": "assets/movie-the-patriot-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T20:50:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2024, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 317639, "tmdb": 7.215, "tmdbVotes": 4535}}, {"id": "movie-peaky-blinders-the-immortal-man-2026", "title": "Peaky Blinders: The Immortal Man", "year": 2026, "type": "movie", "runtimeSeconds": 6844, "seasonCount": null, "communityRating": 7.187, "criticRating": 90, "genres": ["Crime", "Drama"], "overview": "After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.", "poster": "assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg", "backdrop": "assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:01.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 875828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 92130, "tmdb": 7.189, "tmdbVotes": 1304}}, {"id": "movie-ponyo-2008", "title": "Ponyo", "year": 2008, "type": "movie", "runtimeSeconds": 6530, "seasonCount": null, "communityRating": 7.763, "criticRating": 91, "genres": ["Animation", "Fantasy", "Family"], "overview": "When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026", "poster": "assets/movie-ponyo-2008-poster.jpg", "backdrop": "assets/movie-ponyo-2008-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:53.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 12429, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.6, "imdbVotes": 190294, "tmdb": 7.759, "tmdbVotes": 5050}}, {"id": "movie-porco-rosso-1992", "title": "Porco Rosso", "year": 1992, "type": "movie", "runtimeSeconds": 5600, "seasonCount": null, "communityRating": 7.794, "criticRating": 96, "genres": ["Animation", "Adventure", "Fantasy"], "overview": "In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026", "poster": "assets/movie-porco-rosso-1992-poster.jpg", "backdrop": "assets/movie-porco-rosso-1992-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 11621, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 119396, "tmdb": 7.789, "tmdbVotes": 3879}}, {"id": "movie-princess-mononoke-1997", "title": "Princess Mononoke", "year": 1997, "type": "movie", "runtimeSeconds": 8003, "seasonCount": null, "communityRating": 8.321, "criticRating": 93, "genres": ["Adventure", "Fantasy", "Animation"], "overview": "Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026", "poster": "assets/movie-princess-mononoke-1997-poster.jpg", "backdrop": "assets/movie-princess-mononoke-1997-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:28.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 128, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 493735, "tmdb": 8.321, "tmdbVotes": 9438}}, {"id": "movie-prisoners-2013", "title": "Prisoners", "year": 2013, "type": "movie", "runtimeSeconds": 9192, "seasonCount": null, "communityRating": 8.106, "criticRating": 81, "genres": ["Drama", "Thriller", "Crime"], "overview": "After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026", "poster": "assets/movie-prisoners-2013-poster.jpg", "backdrop": "assets/movie-prisoners-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:33.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 146233, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 963062, "tmdb": 8.105, "tmdbVotes": 13722}}, {"id": "movie-racing-stripes-2005", "title": "Racing Stripes", "year": 2005, "type": "movie", "runtimeSeconds": 6117, "seasonCount": null, "communityRating": 5.945, "criticRating": 35, "genres": ["Comedy", "Family"], "overview": "Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026", "poster": "assets/movie-racing-stripes-2005-poster.jpg", "backdrop": "assets/movie-racing-stripes-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 6439, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 35, "rtCriticsLabel": "Rotten", "rtAudience": 42, "rtAudienceLabel": "Spilled", "imdb": 5.3, "imdbVotes": 19569, "tmdb": 5.962, "tmdbVotes": 705}}, {"id": "movie-requiem-for-a-dream-2000", "title": "Requiem for a Dream", "year": 2000, "type": "movie", "runtimeSeconds": 6086, "seasonCount": null, "communityRating": 8.005, "criticRating": 80, "genres": ["Crime", "Drama"], "overview": "The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.", "poster": "assets/movie-requiem-for-a-dream-2000-poster.jpg", "backdrop": "assets/movie-requiem-for-a-dream-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 641, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.3, "imdbVotes": 989090, "tmdb": 8, "tmdbVotes": 11454}}, {"id": "movie-rogue-one-a-star-wars-story-2016", "title": "Rogue One: A Star Wars Story", "year": 2016, "type": "movie", "runtimeSeconds": 8038, "seasonCount": null, "communityRating": 7.51, "criticRating": 84, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.", "poster": "assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg", "backdrop": "assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 330459, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 768365, "tmdb": 7.512, "tmdbVotes": 16937}}, {"id": "movie-secondhand-lions-2003", "title": "Secondhand Lions", "year": 2003, "type": "movie", "runtimeSeconds": 6547, "seasonCount": null, "communityRating": 7.237, "criticRating": 60, "genres": ["Family", "Comedy", "Drama"], "overview": "The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.", "poster": "assets/movie-secondhand-lions-2003-poster.jpg", "backdrop": "assets/movie-secondhand-lions-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-12T07:16:13.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 13156, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 59, "rtCriticsLabel": "Rotten", "rtAudience": 84, "rtAudienceLabel": "Upright", "imdb": 7.5, "imdbVotes": 66911, "tmdb": 7.242, "tmdbVotes": 872}}, {"id": "movie-the-secret-life-of-pets-2-2019", "title": "The Secret Life of Pets 2", "year": 2019, "type": "movie", "runtimeSeconds": 5164, "seasonCount": null, "communityRating": 6.95, "criticRating": 60, "genres": ["Animation", "Comedy", "Family"], "overview": "Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026", "poster": "assets/movie-the-secret-life-of-pets-2-2019-poster.jpg", "backdrop": "assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-05T07:00:53.0000000Z", "lastPlayedAt": "2026-09-17T18:07:00.7024231Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 412117, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 6.4, "imdbVotes": 80464, "tmdb": 6.948, "tmdbVotes": 3450}}, {"id": "movie-self-reliance-2024", "title": "Self Reliance", "year": 2024, "type": "movie", "runtimeSeconds": 5461, "seasonCount": null, "communityRating": 5.888, "criticRating": 73, "genres": ["Comedy", "Thriller"], "overview": "When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026", "poster": "assets/movie-self-reliance-2024-poster.jpg", "backdrop": "assets/movie-self-reliance-2024-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:00.0000000Z", "lastPlayedAt": "2026-10-02T19:59:16.9286868Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1072876, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 73, "rtCriticsLabel": "Certified Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 23771, "tmdb": 5.866, "tmdbVotes": 346}}, {"id": "movie-serpico-1973", "title": "Serpico", "year": 1973, "type": "movie", "runtimeSeconds": 7805, "seasonCount": null, "communityRating": 7.5, "criticRating": 93, "genres": ["Crime", "Drama", "History"], "overview": "New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.", "poster": "assets/movie-serpico-1973-poster.jpg", "backdrop": "assets/movie-serpico-1973-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:45:02.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 9040, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 149750, "tmdb": 7.525, "tmdbVotes": 2323}}, {"id": "movie-shaft-2000", "title": "Shaft", "year": 2000, "type": "movie", "runtimeSeconds": 5960, "seasonCount": null, "communityRating": 6.026, "criticRating": 67, "genres": ["Action", "Adventure", "Crime"], "overview": "New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026", "poster": "assets/movie-shaft-2000-poster.jpg", "backdrop": "assets/movie-shaft-2000-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T21:30:40.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 479, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 68, "rtCriticsLabel": "Fresh", "rtAudience": 40, "rtAudienceLabel": "Spilled", "imdb": 6, "imdbVotes": 86257, "tmdb": 6.024, "tmdbVotes": 1531}}, {"id": "movie-a-silent-voice-the-movie-2016", "title": "A Silent Voice: The Movie", "year": 2016, "type": "movie", "runtimeSeconds": 7804, "seasonCount": null, "communityRating": 8.398, "criticRating": null, "genres": ["Animation", "Drama", "Romance"], "overview": "Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026", "poster": "assets/movie-a-silent-voice-the-movie-2016-poster.jpg", "backdrop": "assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T17:39:46.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 378064, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": 94, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 151926, "tmdb": 8.396, "tmdbVotes": 4764}}, {"id": "movie-sinbad-legend-of-the-seven-seas-2003", "title": "Sinbad: Legend of the Seven Seas", "year": 2003, "type": "movie", "runtimeSeconds": 5143, "seasonCount": null, "communityRating": 7.067, "criticRating": 44, "genres": ["Action", "Adventure", "Animation"], "overview": "The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.", "poster": "assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg", "backdrop": "assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:26:25.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 14411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 57, "rtAudienceLabel": "Spilled", "imdb": 6.8, "imdbVotes": 65298, "tmdb": 7.071, "tmdbVotes": 2323}}, {"id": "movie-sleeping-with-other-people-2015", "title": "Sleeping with Other People", "year": 2015, "type": "movie", "runtimeSeconds": 6076, "seasonCount": null, "communityRating": 6.274, "criticRating": 64, "genres": ["Comedy", "Romance"], "overview": "Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026", "poster": "assets/movie-sleeping-with-other-people-2015-poster.jpg", "backdrop": "assets/movie-sleeping-with-other-people-2015-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:42.0000000Z", "lastPlayedAt": "2026-07-17T17:50:44.0000000Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 288036, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 56, "rtAudienceLabel": "Spilled", "imdb": 6.5, "imdbVotes": 52275, "tmdb": 6.275, "tmdbVotes": 1020}}, {"id": "movie-snowpiercer-2013", "title": "Snowpiercer", "year": 2013, "type": "movie", "runtimeSeconds": 7573, "seasonCount": null, "communityRating": 6.909, "criticRating": 94, "genres": ["Action", "Science Fiction", "Drama"], "overview": "In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.", "poster": "assets/movie-snowpiercer-2013-poster.jpg", "backdrop": "assets/movie-snowpiercer-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:27:07.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110415, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Certified Fresh", "rtAudience": 72, "rtAudienceLabel": "Upright", "imdb": 7.1, "imdbVotes": 426976, "tmdb": 6.9, "tmdbVotes": 10807}}, {"id": "movie-spider-man-brand-new-day-2026", "title": "Spider-Man: Brand New Day", "year": 2026, "type": "movie", "runtimeSeconds": 8701, "seasonCount": null, "communityRating": 8.374, "criticRating": 90, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026", "poster": "assets/movie-spider-man-brand-new-day-2026-poster.jpg", "backdrop": "assets/movie-spider-man-brand-new-day-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:14:27.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 969681, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 7.9, "imdbVotes": 366886, "tmdb": 8.5, "tmdbVotes": 6537}}, {"id": "movie-spider-man-far-from-home-2019", "title": "Spider-Man: Far From Home", "year": 2019, "type": "movie", "runtimeSeconds": 7762, "seasonCount": null, "communityRating": 7.389, "criticRating": 91, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026", "poster": "assets/movie-spider-man-far-from-home-2019-poster.jpg", "backdrop": "assets/movie-spider-man-far-from-home-2019-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:25:47.0000000Z", "lastPlayedAt": "2026-10-10T17:50:35.6193119Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 95, "rtAudienceLabel": "Upright", "imdb": 7.3, "imdbVotes": 682887, "tmdb": 7.387, "tmdbVotes": 17847}}, {"id": "movie-spider-man-homecoming-2017", "title": "Spider-Man: Homecoming", "year": 2017, "type": "movie", "runtimeSeconds": 8008, "seasonCount": null, "communityRating": 7.333, "criticRating": 92, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026", "poster": "assets/movie-spider-man-homecoming-2017-poster.jpg", "backdrop": "assets/movie-spider-man-homecoming-2017-backdrop.jpg", "played": true, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:33:13.0000000Z", "lastPlayedAt": "2026-10-10T20:31:23.4444213Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 315635, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Certified Fresh", "rtAudience": 87, "rtAudienceLabel": "Upright", "imdb": 7.4, "imdbVotes": 840974, "tmdb": 7.332, "tmdbVotes": 24183}}, {"id": "movie-spider-man-no-way-home-2021", "title": "Spider-Man: No Way Home", "year": 2021, "type": "movie", "runtimeSeconds": 8890, "seasonCount": null, "communityRating": 7.947, "criticRating": 93, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026", "poster": "assets/movie-spider-man-no-way-home-2021-poster.jpg", "backdrop": "assets/movie-spider-man-no-way-home-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-10T14:21:05.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 634649, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 97, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 1082660, "tmdb": 7.947, "tmdbVotes": 23505}}, {"id": "movie-spirited-away-2001", "title": "Spirited Away", "year": 2001, "type": "movie", "runtimeSeconds": 7472, "seasonCount": null, "communityRating": 8.535, "criticRating": 96, "genres": ["Animation", "Family", "Fantasy"], "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", "poster": "assets/movie-spirited-away-2001-poster.jpg", "backdrop": "assets/movie-spirited-away-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:42.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 129, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": 99, "rtAudienceLabel": "Upright", "imdb": 8.6, "imdbVotes": 987658, "tmdb": 8.536, "tmdbVotes": 19176}}, {"id": "movie-the-substance-2024", "title": "The Substance", "year": 2024, "type": "movie", "runtimeSeconds": 8531, "seasonCount": null, "communityRating": 7.135, "criticRating": 89, "genres": ["Horror", "Science Fiction"], "overview": "A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.", "poster": "assets/movie-the-substance-2024-poster.jpg", "backdrop": "assets/movie-the-substance-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:22.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 933260, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 76, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 466542, "tmdb": 7.13, "tmdbVotes": 6746}}, {"id": "movie-the-tale-of-the-princess-kaguya-2013", "title": "The Tale of The Princess Kaguya", "year": 2013, "type": "movie", "runtimeSeconds": 8218, "seasonCount": null, "communityRating": 8.1, "criticRating": 100, "genres": ["Animation", "Drama", "Fantasy"], "overview": "Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026", "poster": "assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg", "backdrop": "assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-07T13:40:15.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149871, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 63574, "tmdb": 8.083, "tmdbVotes": 2079}}, {"id": "movie-taxi-driver-1976", "title": "Taxi Driver", "year": 1976, "type": "movie", "runtimeSeconds": 6831, "seasonCount": null, "communityRating": 8.121, "criticRating": 89, "genres": ["Crime", "Drama"], "overview": "Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.", "poster": "assets/movie-taxi-driver-1976-poster.jpg", "backdrop": "assets/movie-taxi-driver-1976-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:23:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 103, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 93, "rtAudienceLabel": "Upright", "imdb": 8.2, "imdbVotes": 1046700, "tmdb": 8.116, "tmdbVotes": 13973}}, {"id": "movie-triangle-of-sadness-2022", "title": "Triangle of Sadness", "year": 2022, "type": "movie", "runtimeSeconds": 8833, "seasonCount": null, "communityRating": 7.005, "criticRating": 71, "genres": ["Comedy", "Drama"], "overview": "A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026", "poster": "assets/movie-triangle-of-sadness-2022-poster.jpg", "backdrop": "assets/movie-triangle-of-sadness-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:29:00.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 497828, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": 80, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 221069, "tmdb": 7, "tmdbVotes": 3104}}, {"id": "movie-tsotsi-2005", "title": "Tsotsi", "year": 2005, "type": "movie", "runtimeSeconds": 5663, "seasonCount": null, "communityRating": 6.937, "criticRating": 82, "genres": ["Crime", "Drama"], "overview": "A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026", "poster": "assets/movie-tsotsi-2005-poster.jpg", "backdrop": "assets/movie-tsotsi-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 868, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 82, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 32526, "tmdb": 6.941, "tmdbVotes": 372}}, {"id": "movie-unknown-number-the-high-school-catfish-2025", "title": "Unknown Number: The High School Catfish", "year": 2025, "type": "movie", "runtimeSeconds": 5691, "seasonCount": null, "communityRating": 6.746, "criticRating": 85, "genres": ["Documentary"], "overview": "Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.", "poster": "assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg", "backdrop": "assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:32:10.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1517772, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 85, "rtCriticsLabel": "Fresh", "rtAudience": 69, "rtAudienceLabel": "Upright", "imdb": 6.7, "imdbVotes": 23039, "tmdb": 6.737, "tmdbVotes": 257}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-vice-2018", "title": "Vice", "year": 2018, "type": "movie", "runtimeSeconds": 7948, "seasonCount": null, "communityRating": 7.013, "criticRating": 64, "genres": ["Drama", "Comedy", "History"], "overview": "George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026", "poster": "assets/movie-vice-2018-poster.jpg", "backdrop": "assets/movie-vice-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:28:30.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 429197, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 177299, "tmdb": 7.014, "tmdbVotes": 3723}}, {"id": "movie-warriors-of-the-wind-1984", "title": "Warriors of the Wind", "year": 1984, "type": "movie", "runtimeSeconds": 7022, "seasonCount": null, "communityRating": 7.941, "criticRating": 87, "genres": ["Adventure", "Animation", "Fantasy"], "overview": "After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026", "poster": "assets/movie-warriors-of-the-wind-1984-poster.jpg", "backdrop": "assets/movie-warriors-of-the-wind-1984-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:22:20.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 81, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 200154, "tmdb": 7.94, "tmdbVotes": 4260}}, {"id": "movie-we-bury-the-dead-2026", "title": "We Bury the Dead", "year": 2026, "type": "movie", "runtimeSeconds": 5693, "seasonCount": null, "communityRating": 6.153, "criticRating": 87, "genres": ["Horror", "Thriller"], "overview": "After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.", "poster": "assets/movie-we-bury-the-dead-2026-poster.jpg", "backdrop": "assets/movie-we-bury-the-dead-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-08-26T05:36:00.4272328Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1198984, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Certified Fresh", "rtAudience": 45, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 20693, "tmdb": 6.137, "tmdbVotes": 433}}, {"id": "movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022", "title": "White Hot: The Rise & Fall of Abercrombie & Fitch", "year": 2022, "type": "movie", "runtimeSeconds": 5187, "seasonCount": null, "communityRating": 5.663, "criticRating": 66, "genres": ["Documentary"], "overview": "All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.", "poster": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg", "backdrop": "assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:30:34.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 953371, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": 29, "rtAudienceLabel": "Spilled", "imdb": 5.8, "imdbVotes": 5655, "tmdb": 5.7, "tmdbVotes": 102}}, {"id": "movie-the-wild-robot-2024", "title": "The Wild Robot", "year": 2024, "type": "movie", "runtimeSeconds": 6101, "seasonCount": null, "communityRating": 8.308, "criticRating": 97, "genres": ["Family", "Animation", "Science Fiction"], "overview": "After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.", "poster": "assets/movie-the-wild-robot-2024-poster.jpg", "backdrop": "assets/movie-the-wild-robot-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:31:49.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1184918, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Certified Fresh", "rtAudience": 98, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 241134, "tmdb": 8.309, "tmdbVotes": 6819}}, {"id": "movie-the-wind-rises-2013", "title": "The Wind Rises", "year": 2013, "type": "movie", "runtimeSeconds": 8031, "seasonCount": null, "communityRating": 7.784, "criticRating": 88, "genres": ["Animation", "Drama", "History"], "overview": "A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.", "poster": "assets/movie-the-wind-rises-2013-poster.jpg", "backdrop": "assets/movie-the-wind-rises-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T10:24:38.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 149870, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 85, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 115756, "tmdb": 7.783, "tmdbVotes": 3536}}, {"id": "movie-the-witcher-nightmare-of-the-wolf-2021", "title": "The Witcher: Nightmare of the Wolf", "year": 2021, "type": "movie", "runtimeSeconds": 5015, "seasonCount": null, "communityRating": 7.422, "criticRating": 100, "genres": ["Animation", "Action", "Fantasy"], "overview": "Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.", "poster": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg", "backdrop": "assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:42:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 666243, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": 83, "rtAudienceLabel": "Upright", "imdb": 7.2, "imdbVotes": 55759, "tmdb": 7.407, "tmdbVotes": 1159}}, {"id": "movie-the-witcher-sirens-of-the-deep-2025", "title": "The Witcher: Sirens of the Deep", "year": 2025, "type": "movie", "runtimeSeconds": 5517, "seasonCount": null, "communityRating": 6.8, "criticRating": 60, "genres": ["Animation", "Fantasy", "Action"], "overview": "When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.", "poster": "assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg", "backdrop": "assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:03:26.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1203329, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 60, "rtCriticsLabel": "Fresh", "rtAudience": 37, "rtAudienceLabel": "Spilled", "imdb": 6.1, "imdbVotes": 11799, "tmdb": 6.741, "tmdbVotes": 303}}], "shows": [{"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-american-crime-story-2016", "title": "American Crime Story", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.573, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "An anthology series centered around some of history's most famous criminal investigations.", "poster": "assets/tv-american-crime-story-2016-poster.jpg", "backdrop": "assets/tv-american-crime-story-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0490488Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 64513, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.585, "tmdbVotes": 1094}}, {"id": "tv-american-primeval-2025", "title": "American Primeval", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.691, "criticRating": null, "genres": ["Western", "Drama", "Action & Adventure"], "overview": "A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.", "poster": "assets/tv-american-primeval-2025-poster.jpg", "backdrop": "assets/tv-american-primeval-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0573221Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 215866, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 71, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.741, "tmdbVotes": 538}}, {"id": "tv-arrested-development-2003", "title": "Arrested Development", "year": 2003, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 7.925, "criticRating": null, "genres": ["Comedy"], "overview": "The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.", "poster": "assets/tv-arrested-development-2003-poster.jpg", "backdrop": "assets/tv-arrested-development-2003-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0513048Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 4589, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.906, "tmdbVotes": 1793}}, {"id": "tv-avatar-the-last-airbender-2005", "title": "Avatar: The Last Airbender", "year": 2005, "type": "tv", "runtimeSeconds": 1440, "seasonCount": 3, "communityRating": 8.78, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.", "poster": "assets/tv-avatar-the-last-airbender-2005-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0504536Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 246, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.787, "tmdbVotes": 5215}}, {"id": "tv-b-b-vol-liefde-2021", "title": "B&B Vol Liefde", "year": 2021, "type": "tv", "runtimeSeconds": 3720, "seasonCount": 1, "communityRating": 7.2, "criticRating": null, "genres": ["Reality"], "overview": "B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026", "poster": "assets/tv-b-b-vol-liefde-2021-poster.jpg", "backdrop": "assets/tv-b-b-vol-liefde-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-31T22:30:11.129657Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 124428, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.2, "tmdbVotes": 10}}, {"id": "tv-the-bear-2022", "title": "The Bear", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.139, "criticRating": null, "genres": ["Drama", "Comedy"], "overview": "Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026", "poster": "assets/tv-the-bear-2022-poster.jpg", "backdrop": "assets/tv-the-bear-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0467371Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 136315, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.158, "tmdbVotes": 2025}}, {"id": "tv-blue-planet-ii-2017", "title": "Blue Planet II", "year": 2017, "type": "tv", "runtimeSeconds": 3600, "seasonCount": 1, "communityRating": 8.64, "criticRating": null, "genres": ["Documentary", "Family"], "overview": "There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.", "poster": "assets/tv-blue-planet-ii-2017-poster.jpg", "backdrop": "assets/tv-blue-planet-ii-2017-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0634849Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 74313, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 380}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-chernobyl-2019", "title": "Chernobyl", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.718, "criticRating": null, "genres": ["Drama"], "overview": "The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.", "poster": "assets/tv-chernobyl-2019-poster.jpg", "backdrop": "assets/tv-chernobyl-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0469903Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 87108, "artwork": {"posterKind": "poster", "backdropKind": "center_crop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 8496}}, {"id": "tv-community-2009", "title": "Community", "year": 2009, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 7.997, "criticRating": null, "genres": ["Comedy"], "overview": "Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.", "poster": "assets/tv-community-2009-poster.jpg", "backdrop": "assets/tv-community-2009-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0531416Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 18347, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.994, "tmdbVotes": 2226}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dopesick-2021", "title": "Dopesick", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.015, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026", "poster": "assets/tv-dopesick-2021-poster.jpg", "backdrop": "assets/tv-dopesick-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0580168Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 110695, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.035, "tmdbVotes": 565}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "tv-fallout-2024", "title": "Fallout", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.124, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026", "poster": "assets/tv-fallout-2024-poster.jpg", "backdrop": "assets/tv-fallout-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0669262Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 106379, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.106, "tmdbVotes": 3233}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}, {"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-the-get-down-2016", "title": "The Get Down", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.3, "criticRating": null, "genres": ["Drama"], "overview": "In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.", "poster": "assets/tv-the-get-down-2016-poster.jpg", "backdrop": "assets/tv-the-get-down-2016-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0497737Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 65345, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.3, "tmdbVotes": 196}}, {"id": "tv-how-to-change-your-mind-2022", "title": "How to Change Your Mind", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.76, "criticRating": null, "genres": ["Documentary", "Mystery"], "overview": "Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.", "poster": "assets/tv-how-to-change-your-mind-2022-poster.jpg", "backdrop": "assets/tv-how-to-change-your-mind-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0601399Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 204304, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 53}}, {"id": "tv-interrogation-2020", "title": "Interrogation", "year": 2020, "type": "tv", "runtimeSeconds": 2700, "seasonCount": 1, "communityRating": 6.788, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026", "poster": "assets/tv-interrogation-2020-poster.jpg", "backdrop": "assets/tv-interrogation-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T05:52:06.1068207Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 96488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.9, "tmdbVotes": 54}}, {"id": "tv-it-s-always-sunny-in-philadelphia-2005", "title": "It's Always Sunny in Philadelphia", "year": 2005, "type": "tv", "runtimeSeconds": null, "seasonCount": 18, "communityRating": 8.2, "criticRating": null, "genres": ["Comedy"], "overview": "Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026", "poster": "assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg", "backdrop": "assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0485774Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2710, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.253, "tmdbVotes": 1457}}, {"id": "tv-a-knight-of-the-seven-kingdoms-2026", "title": "A Knight of the Seven Kingdoms", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.431, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy", "Action & Adventure"], "overview": "A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026", "poster": "assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg", "backdrop": "assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0493344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 224372, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 1266}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-the-last-of-us-2023", "title": "The Last of Us", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.419, "criticRating": null, "genres": ["Drama"], "overview": "Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026", "poster": "assets/tv-the-last-of-us-2023-poster.jpg", "backdrop": "assets/tv-the-last-of-us-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0640955Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 100088, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.4, "tmdbVotes": 7485}}, {"id": "tv-last-samurai-standing-2025", "title": "Last Samurai Standing", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.805, "criticRating": null, "genres": ["Action & Adventure", "Drama"], "overview": "In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.", "poster": "assets/tv-last-samurai-standing-2025-poster.jpg", "backdrop": "assets/tv-last-samurai-standing-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0622526Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 252193, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.812, "tmdbVotes": 271}}, {"id": "tv-lord-of-the-flies-2026", "title": "Lord of the Flies", "year": 2026, "type": "tv", "runtimeSeconds": 3480, "seasonCount": 1, "communityRating": 7.571, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026", "poster": "assets/tv-lord-of-the-flies-2026-poster.jpg", "backdrop": "assets/tv-lord-of-the-flies-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0645648Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270572, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.58, "tmdbVotes": 138}}, {"id": "tv-the-lord-of-the-rings-the-rings-of-power-2022", "title": "The Lord of the Rings: The Rings of Power", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.233, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026", "poster": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg", "backdrop": "assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0618904Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 84773, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.211, "tmdbVotes": 4003}}, {"id": "tv-love-death-robots-2019", "title": "Love, Death & Robots", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 8.21, "criticRating": null, "genres": ["Animation", "Sci-Fi & Fantasy"], "overview": "Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.", "poster": "assets/tv-love-death-robots-2019-poster.jpg", "backdrop": "assets/tv-love-death-robots-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0460964Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86831, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.203, "tmdbVotes": 4238}}, {"id": "tv-maniac-2018", "title": "Maniac", "year": 2018, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.338, "criticRating": null, "genres": ["Comedy", "Drama", "Sci-Fi & Fantasy"], "overview": "Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.", "poster": "assets/tv-maniac-2018-poster.jpg", "backdrop": "assets/tv-maniac-2018-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-20T19:42:30.955548Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 73411, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 84, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.323, "tmdbVotes": 1073}}, {"id": "tv-mussolini-son-of-the-century-2024", "title": "Mussolini: Son of the Century", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.135, "criticRating": null, "genres": ["Drama", "War & Politics"], "overview": "The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.", "poster": "assets/tv-mussolini-son-of-the-century-2024-poster.jpg", "backdrop": "assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0586588Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 212333, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 179}}, {"id": "tv-the-night-manager-2016", "title": "The Night Manager", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 7.74, "criticRating": null, "genres": ["Drama", "Mystery", "Crime"], "overview": "Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026", "poster": "assets/tv-the-night-manager-2016-poster.jpg", "backdrop": "assets/tv-the-night-manager-2016-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0663672Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61859, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 1422}}, {"id": "tv-one-piece-2023", "title": "ONE PIECE", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.118, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.", "poster": "assets/tv-one-piece-2023-poster.jpg", "backdrop": "assets/tv-one-piece-2023-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0439378Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111110, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.104, "tmdbVotes": 1988}}, {"id": "tv-only-murders-in-the-building-2021", "title": "Only Murders in the Building", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.438, "criticRating": null, "genres": ["Comedy", "Mystery", "Crime"], "overview": "Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.", "poster": "assets/tv-only-murders-in-the-building-2021-poster.jpg", "backdrop": "assets/tv-only-murders-in-the-building-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0594614Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 107113, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.398, "tmdbVotes": 2333}}, {"id": "tv-over-the-garden-wall-2014", "title": "Over the Garden Wall", "year": 2014, "type": "tv", "runtimeSeconds": 720, "seasonCount": 1, "communityRating": 8.6, "criticRating": null, "genres": ["Mystery", "Sci-Fi & Fantasy", "Animation"], "overview": "Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026", "poster": "assets/tv-over-the-garden-wall-2014-poster.jpg", "backdrop": "assets/tv-over-the-garden-wall-2014-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0455757Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 61617, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.6, "tmdbVotes": 1854}}, {"id": "tv-peaky-blinders-2013", "title": "Peaky Blinders", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": 6, "communityRating": 8.5, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.", "poster": "assets/tv-peaky-blinders-2013-poster.jpg", "backdrop": "assets/tv-peaky-blinders-2013-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0539185Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 60574, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.5, "tmdbVotes": 11506}}, {"id": "tv-the-pitt-2025", "title": "The Pitt", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.729, "criticRating": null, "genres": ["Drama"], "overview": "The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.", "poster": "assets/tv-the-pitt-2025-poster.jpg", "backdrop": "assets/tv-the-pitt-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0582942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 250307, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.731, "tmdbVotes": 1046}}, {"id": "tv-pluribus-2025", "title": "Pluribus", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.936, "criticRating": null, "genres": ["Drama", "Sci-Fi & Fantasy"], "overview": "The most miserable person on Earth must save the world from happiness.", "poster": "assets/tv-pluribus-2025-poster.jpg", "backdrop": "assets/tv-pluribus-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0674462Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 225171, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 99, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.921, "tmdbVotes": 1191}}, {"id": "tv-ripley-2024", "title": "RIPLEY", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.911, "criticRating": null, "genres": ["Drama"], "overview": "A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.", "poster": "assets/tv-ripley-2024-poster.jpg", "backdrop": "assets/tv-ripley-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0658942Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 94028, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 86, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.909, "tmdbVotes": 453}}, {"id": "tv-say-nothing-2024", "title": "Say Nothing", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.462, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026", "poster": "assets/tv-say-nothing-2024-poster.jpg", "backdrop": "assets/tv-say-nothing-2024-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0499752Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 245256, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 94, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.475, "tmdbVotes": 122}}, {"id": "tv-severance-2022", "title": "Severance", "year": 2022, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.396, "criticRating": null, "genres": ["Drama", "Mystery", "Sci-Fi & Fantasy"], "overview": "Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026", "poster": "assets/tv-severance-2022-poster.jpg", "backdrop": "assets/tv-severance-2022-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0598478Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 95396, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.389, "tmdbVotes": 3095}}, {"id": "tv-six-feet-under-2001", "title": "Six Feet Under", "year": 2001, "type": "tv", "runtimeSeconds": null, "seasonCount": 5, "communityRating": 8.115, "criticRating": null, "genres": ["Drama"], "overview": "When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.", "poster": "assets/tv-six-feet-under-2001-poster.jpg", "backdrop": "assets/tv-six-feet-under-2001-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-09-26T04:04:05.108787Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 1274, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.1, "tmdbVotes": 1192}}, {"id": "tv-undercover-2019", "title": "Undercover", "year": 2019, "type": "tv", "runtimeSeconds": 3000, "seasonCount": 3, "communityRating": 7.5, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.", "poster": "assets/tv-undercover-2019-poster.jpg", "backdrop": "assets/tv-undercover-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0570153Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 186}}, {"id": "tv-upload-2020", "title": "Upload", "year": 2020, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.781, "criticRating": null, "genres": ["Comedy", "Sci-Fi & Fantasy", "Drama"], "overview": "In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026", "poster": "assets/tv-upload-2020-poster.jpg", "backdrop": "assets/tv-upload-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0566967Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 86248, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 87, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.757, "tmdbVotes": 1511}}, {"id": "tv-the-walk-in-2022", "title": "The Walk-In", "year": 2022, "type": "tv", "runtimeSeconds": 2760, "seasonCount": 1, "communityRating": 6.9, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026", "poster": "assets/tv-the-walk-in-2022-poster.jpg", "backdrop": "assets/tv-the-walk-in-2022-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0654569Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 210664, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 50}}, {"id": "tv-the-white-lotus-2021", "title": "The White Lotus", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.596, "criticRating": null, "genres": ["Comedy", "Drama", "Mystery"], "overview": "Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026", "poster": "assets/tv-the-white-lotus-2021-poster.jpg", "backdrop": "assets/tv-the-white-lotus-2021-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0546438Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 111803, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.599, "tmdbVotes": 1604}}, {"id": "tv-widow-s-bay-2026", "title": "Widow's Bay", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 8.172, "criticRating": null, "genres": ["Drama", "Comedy", "Mystery"], "overview": "The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.", "poster": "assets/tv-widow-s-bay-2026-poster.jpg", "backdrop": "assets/tv-widow-s-bay-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-20T10:27:12.7003979Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 270476, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 98, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8, "tmdbVotes": 585}}, {"id": "tv-the-witcher-2019", "title": "The Witcher", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": 4, "communityRating": 7.905, "criticRating": null, "genres": ["Drama", "Action & Adventure"], "overview": "Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.", "poster": "assets/tv-the-witcher-2019-poster.jpg", "backdrop": "assets/tv-the-witcher-2019-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0552369Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 71912, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 75, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.88, "tmdbVotes": 6997}}, {"id": "tv-yellowjackets-2021", "title": "Yellowjackets", "year": 2021, "type": "tv", "runtimeSeconds": null, "seasonCount": 3, "communityRating": 7.449, "criticRating": null, "genres": ["Drama", "Mystery"], "overview": "This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026", "poster": "assets/tv-yellowjackets-2021-poster.jpg", "backdrop": "assets/tv-yellowjackets-2021-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T15:45:22.0577612Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 117488, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.431, "tmdbVotes": 1070}}], "anime": [], "resume": [{"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "nextUp": [{"id": "episode-no-honeymoon-2020-s1e8", "title": "No Honeymoon", "year": 2020, "type": "episode", "runtimeSeconds": 1499, "seasonCount": null, "communityRating": 6.7, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Paul attempts to focus on his upcoming wedding but is constantly thwarted by his family; Ally is distracted by work, Luke is misbehaving at school and Leah (Stella Gonet) doesn\u2019t understand why they are even\u2026", "poster": "assets/episode-no-honeymoon-2020-s1e8-poster.jpg", "backdrop": "assets/episode-no-honeymoon-2020-s1e8-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T19:00:50.0000000Z", "lastPlayedAt": "2026-11-03T20:57:58.3314947Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Breeders", "seasonNumber": 1, "episodeNumber": 8, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-notapusy-2005-s3e4", "title": "Notapusy", "year": 2005, "type": "episode", "runtimeSeconds": 1334, "seasonCount": null, "communityRating": 8.125, "criticRating": null, "genres": ["Comedy"], "overview": "To prove he's a man to Rita, Michael and his nephew Steve Holt compete in the Church And State Fair's annual triathlon. George Sr. tries to scare some at-risk youth, and Tobias tries to help George-Michael's\u2026", "poster": "assets/episode-notapusy-2005-s3e4-poster.jpg", "backdrop": "assets/episode-notapusy-2005-s3e4-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T12:17:58.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Arrested Development", "seasonNumber": 3, "episodeNumber": 4, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-the-man-who-would-be-vogue-2018-s2e1", "title": "The Man Who Would Be Vogue", "year": 2018, "type": "episode", "runtimeSeconds": 3203, "seasonCount": null, "communityRating": 7.459, "criticRating": null, "genres": ["Biography", "Crime", "Drama"], "overview": "The murder of Gianni Versace turns the eyes of the world onto Miami Beach.", "poster": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-poster.jpg", "backdrop": "assets/episode-the-man-who-would-be-vogue-2018-s2e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:29:35.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "American Crime Story", "seasonNumber": 2, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-tomorrow-2024-s3e1", "title": "Tomorrow", "year": 2024, "type": "episode", "runtimeSeconds": 2207, "seasonCount": null, "communityRating": 7.6, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The next day and the days that led to it.", "poster": "assets/episode-tomorrow-2024-s3e1-poster.jpg", "backdrop": "assets/episode-tomorrow-2024-s3e1-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-07-17T13:46:39.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "The Bear", "seasonNumber": 3, "episodeNumber": 1, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}}, {"id": "episode-episode-1-2025-s1e1", "title": "Episode 1", "year": 2025, "type": "episode", "runtimeSeconds": 3618, "seasonCount": null, "communityRating": 7.357, "criticRating": null, "genres": ["Biography", "Drama", "History"], "overview": "Mussolini had just founded the Fasci di Combattimento when D'Annunzio took Fiume. Mussolini hoped to capitalize on the poet's achievement in the electoral arena.", "poster": "assets/episode-episode-1-2025-s1e1-poster.jpg", "backdrop": "assets/episode-episode-1-2025-s1e1-backdrop.jpg", "played": false, "playCount": 1, "resumeSeconds": 489.55, "progress": 0.1353, "addedAt": "2026-07-17T13:52:48.0000000Z", "lastPlayedAt": "2026-11-04T19:01:49.9731435Z", "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "seriesTitle": "Mussolini: Son of the Century", "seasonNumber": 1, "episodeNumber": 1, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}}], "recentlyAdded": [{"id": "tv-the-final-problem-2026", "title": "The Final Problem", "year": 2026, "type": "tv", "runtimeSeconds": 3420, "seasonCount": 1, "communityRating": 7.041, "criticRating": null, "genres": ["Crime", "Mystery"], "overview": "In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.", "poster": "assets/tv-the-final-problem-2026-poster.jpg", "backdrop": "assets/tv-the-final-problem-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T19:16:12.4822791Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 290720, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.041, "tmdbVotes": 49}}, {"id": "tv-dead-end-job-2026", "title": "Dead-End Job", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.7, "criticRating": null, "genres": ["Mystery"], "overview": "Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.", "poster": "assets/tv-dead-end-job-2026-poster.jpg", "backdrop": "assets/tv-dead-end-job-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:58:01.8352857Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 289517, "artwork": {"posterKind": "center_crop", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 38}}, {"id": "tv-dune-prophecy-2024", "title": "Dune: Prophecy", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.213, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama", "Action & Adventure"], "overview": "Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.", "poster": "assets/tv-dune-prophecy-2024-poster.jpg", "backdrop": "assets/tv-dune-prophecy-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-04T18:23:01.2908568Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 90228, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 64, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.213, "tmdbVotes": 677}}, {"id": "movie-the-uprising-2026", "title": "The Uprising", "year": 2026, "type": "movie", "runtimeSeconds": 7684, "seasonCount": null, "communityRating": 7.846, "criticRating": 55, "genres": ["Action", "Drama", "History"], "overview": "As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.", "poster": "assets/movie-the-uprising-2026-poster.jpg", "backdrop": "assets/movie-the-uprising-2026-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-11-02T16:37:03.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 977942, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 57, "rtCriticsLabel": "Rotten", "rtAudience": 65, "rtAudienceLabel": "Upright", "imdb": 6.2, "imdbVotes": 6622, "tmdb": 7.8, "tmdbVotes": 404}}, {"id": "movie-jennifer-s-body-2009", "title": "Jennifer's Body", "year": 2009, "type": "movie", "runtimeSeconds": 6450, "seasonCount": null, "communityRating": 6.012, "criticRating": 47, "genres": ["Horror", "Comedy"], "overview": "Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.", "poster": "assets/movie-jennifer-s-body-2009-poster.jpg", "backdrop": "assets/movie-jennifer-s-body-2009-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-30T03:37:04.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 19994, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 47, "rtCriticsLabel": "Rotten", "rtAudience": 36, "rtAudienceLabel": "Spilled", "imdb": 5.6, "imdbVotes": 179710, "tmdb": 6.013, "tmdbVotes": 4481}}, {"id": "tv-breeders-2020", "title": "Breeders", "year": 2020, "type": "tv", "runtimeSeconds": 1500, "seasonCount": 4, "communityRating": 6.8, "criticRating": null, "genres": ["Comedy"], "overview": "Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.", "poster": "assets/tv-breeders-2020-poster.jpg", "backdrop": "assets/tv-breeders-2020-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:54:21.8885435Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 93290, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.8, "tmdbVotes": 104}}, {"id": "tv-the-four-seasons-2025", "title": "The Four Seasons", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.879, "criticRating": null, "genres": ["Comedy"], "overview": "The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.", "poster": "assets/tv-the-four-seasons-2025-poster.jpg", "backdrop": "assets/tv-the-four-seasons-2025-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-28T18:36:42.5326384Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 243316, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 81, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.892, "tmdbVotes": 204}}, {"id": "tv-adults-2025", "title": "Adults", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 6.495, "criticRating": null, "genres": ["Comedy"], "overview": "A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.", "poster": "assets/tv-adults-2025-poster.jpg", "backdrop": "assets/tv-adults-2025-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T21:01:41.1814765Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 260458, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 102}}, {"id": "tv-east-of-eden-2026", "title": "East of Eden", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": 1, "communityRating": 7.931, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.", "poster": "assets/tv-east-of-eden-2026-poster.jpg", "backdrop": "assets/tv-east-of-eden-2026-backdrop.jpg", "played": true, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-23T18:07:11.3965096Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 258165, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.982, "tmdbVotes": 139}}, {"id": "movie-fighting-with-my-family-2019", "title": "Fighting with My Family", "year": 2019, "type": "movie", "runtimeSeconds": 6509, "seasonCount": null, "communityRating": 6.84, "criticRating": 93, "genres": ["Comedy"], "overview": "Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026", "poster": "assets/movie-fighting-with-my-family-2019-poster.jpg", "backdrop": "assets/movie-fighting-with-my-family-2019-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T22:10:59.0000000Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 445629, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Certified Fresh", "rtAudience": 86, "rtAudienceLabel": "Upright", "imdb": 7, "imdbVotes": 94576, "tmdb": 6.837, "tmdbVotes": 1952}}, {"id": "tv-landman-2024", "title": "Landman", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": 2, "communityRating": 8.194, "criticRating": null, "genres": ["Drama"], "overview": "Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026", "poster": "assets/tv-landman-2024-poster.jpg", "backdrop": "assets/tv-landman-2024-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-22T21:38:18.0973875Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 157741, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.209, "tmdbVotes": 668}}, {"id": "tv-fawlty-towers-1975", "title": "Fawlty Towers", "year": 1975, "type": "tv", "runtimeSeconds": 1800, "seasonCount": 2, "communityRating": 8.295, "criticRating": null, "genres": ["Comedy"], "overview": "Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.", "poster": "assets/tv-fawlty-towers-1975-poster.jpg", "backdrop": "assets/tv-fawlty-towers-1975-backdrop.jpg", "played": false, "playCount": 0, "resumeSeconds": 0.0, "progress": 0, "addedAt": "2026-10-19T23:26:39.7658344Z", "lastPlayedAt": null, "availability": "in_library", "availabilityEvidence": "viewer_scoped_jellyfin_library", "source": "jellyfin_library", "tmdbId": 2207, "artwork": {"posterKind": "poster", "backdropKind": "backdrop"}, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.296, "tmdbVotes": 867}}], "discover": [{"id": "movie-12-years-a-slave-2013", "title": "12 Years a Slave", "year": 2013, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "History"], "overview": "In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026", "poster": "assets/movie-12-years-a-slave-2013-poster.jpg", "backdrop": "assets/movie-12-years-a-slave-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 76203, "ratings": {"rtCritics": 95, "rtCriticsLabel": "Certified Fresh", "rtAudience": 90, "rtAudienceLabel": "Upright", "imdb": 8.1, "imdbVotes": 796313, "tmdb": 7.927, "tmdbVotes": 12335}}, {"id": "movie-101-dalmatians-ii-patch-s-london-adventure-2003", "title": "101 Dalmatians II: Patch's London Adventure", "year": 2003, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Family", "Animation", "Adventure"], "overview": "Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.", "poster": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg", "backdrop": "assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 13654, "ratings": {"rtCritics": 67, "rtCriticsLabel": "Fresh", "rtAudience": 41, "rtAudienceLabel": "Spilled", "imdb": 5.7, "imdbVotes": 12343, "tmdb": 6.025, "tmdbVotes": 1274}}, {"id": "movie-absolution-2024", "title": "Absolution", "year": 2024, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Crime", "Thriller"], "overview": "An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026", "poster": "assets/movie-absolution-2024-poster.jpg", "backdrop": "assets/movie-absolution-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 974453, "ratings": {"rtCritics": 56, "rtCriticsLabel": "Rotten", "rtAudience": 32, "rtAudienceLabel": "Spilled", "imdb": 5.2, "imdbVotes": 11413, "tmdb": 5.906, "tmdbVotes": 564}}, {"id": "movie-addicted-2014", "title": "Addicted", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.", "poster": "assets/movie-addicted-2014-poster.jpg", "backdrop": "assets/movie-addicted-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 235271, "ratings": {"rtCritics": 7, "rtCriticsLabel": "Rotten", "rtAudience": 51, "rtAudienceLabel": "Spilled", "imdb": 5, "imdbVotes": 13131, "tmdb": 5.928, "tmdbVotes": 466}}, {"id": "movie-all-the-money-in-the-world-2017", "title": "All the Money in the World", "year": 2017, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "History", "Thriller"], "overview": "The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.", "poster": "assets/movie-all-the-money-in-the-world-2017-poster.jpg", "backdrop": "assets/movie-all-the-money-in-the-world-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 446791, "ratings": {"rtCritics": 79, "rtCriticsLabel": "Certified Fresh", "rtAudience": 66, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 97448, "tmdb": 6.493, "tmdbVotes": 2493}}, {"id": "movie-the-alto-knights-2025", "title": "The Alto Knights", "year": 2025, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026", "poster": "assets/movie-the-alto-knights-2025-poster.jpg", "backdrop": "assets/movie-the-alto-knights-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1013601, "ratings": {"rtCritics": 39, "rtCriticsLabel": "Rotten", "rtAudience": 71, "rtAudienceLabel": "Upright", "imdb": 5.9, "imdbVotes": 23017, "tmdb": 6.223, "tmdbVotes": 485}}, {"id": "movie-the-amazing-spider-man-2-2014", "title": "The Amazing Spider-Man 2", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026", "poster": "assets/movie-the-amazing-spider-man-2-2014-poster.jpg", "backdrop": "assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 102382, "ratings": {"rtCritics": 51, "rtCriticsLabel": "Rotten", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.6, "imdbVotes": 611981, "tmdb": 6.567, "tmdbVotes": 14919}}, {"id": "movie-ant-man-and-the-wasp-quantumania-2023", "title": "Ant-Man and the Wasp: Quantumania", "year": 2023, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026", "poster": "assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg", "backdrop": "assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 640146, "ratings": {"rtCritics": 46, "rtCriticsLabel": "Rotten", "rtAudience": 81, "rtAudienceLabel": "Upright", "imdb": 6, "imdbVotes": 281738, "tmdb": 6.197, "tmdbVotes": 6236}}, {"id": "movie-arctic-dogs-2019", "title": "Arctic Dogs", "year": 2019, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Adventure", "Comedy"], "overview": "Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.", "poster": "assets/movie-arctic-dogs-2019-poster.jpg", "backdrop": "assets/movie-arctic-dogs-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 416153, "ratings": {"rtCritics": 13, "rtCriticsLabel": "Rotten", "rtAudience": 60, "rtAudienceLabel": "Upright", "imdb": 4.9, "imdbVotes": 2580, "tmdb": 5.9, "tmdbVotes": 148}}, {"id": "movie-the-avengers-2012", "title": "The Avengers", "year": 2012, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Science Fiction", "Action", "Adventure"], "overview": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026", "poster": "assets/movie-the-avengers-2012-poster.jpg", "backdrop": "assets/movie-the-avengers-2012-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 24428, "ratings": {"rtCritics": 91, "rtCriticsLabel": "Certified Fresh", "rtAudience": 91, "rtAudienceLabel": "Upright", "imdb": 8, "imdbVotes": 1590693, "tmdb": 8.084, "tmdbVotes": 40262}}, {"id": "movie-black-bear-2020", "title": "Black Bear", "year": 2020, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026", "poster": "assets/movie-black-bear-2020-poster.jpg", "backdrop": "assets/movie-black-bear-2020-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 624788, "ratings": {"rtCritics": 89, "rtCriticsLabel": "Certified Fresh", "rtAudience": 64, "rtAudienceLabel": "Upright", "imdb": 6.5, "imdbVotes": 18408, "tmdb": 6.337, "tmdbVotes": 315}}, {"id": "movie-black-panther-2018", "title": "Black Panther", "year": 2018, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026", "poster": "assets/movie-black-panther-2018-poster.jpg", "backdrop": "assets/movie-black-panther-2018-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284054, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Certified Fresh", "rtAudience": null, "rtAudienceLabel": "Spilled", "imdb": 7.3, "imdbVotes": 931120, "tmdb": 7.36, "tmdbVotes": 24190}}, {"id": "movie-a-bronx-tale-1993", "title": "A Bronx Tale", "year": 1993, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026", "poster": "assets/movie-a-bronx-tale-1993-poster.jpg", "backdrop": "assets/movie-a-bronx-tale-1993-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1607, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 191930, "tmdb": 7.913, "tmdbVotes": 3075}}, {"id": "movie-by-any-means-2026", "title": "By Any Means", "year": 2026, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Thriller", "History", "Crime"], "overview": "In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.", "poster": "assets/movie-by-any-means-2026-poster.jpg", "backdrop": "assets/movie-by-any-means-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1380417, "ratings": {"rtCritics": 49, "rtCriticsLabel": "Rotten", "rtAudience": 88, "rtAudienceLabel": "Upright", "imdb": 6.8, "imdbVotes": 3534, "tmdb": 6.6, "tmdbVotes": 92}}, {"id": "movie-captain-america-civil-war-2016", "title": "Captain America: Civil War", "year": 2016, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Adventure", "Action", "Science Fiction"], "overview": "Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026", "poster": "assets/movie-captain-america-civil-war-2016-poster.jpg", "backdrop": "assets/movie-captain-america-civil-war-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 271110, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 89, "rtAudienceLabel": "Upright", "imdb": 7.8, "imdbVotes": 954943, "tmdb": 7.455, "tmdbVotes": 24808}}, {"id": "movie-captain-america-the-first-avenger-2011", "title": "Captain America: The First Avenger", "year": 2011, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026", "poster": "assets/movie-captain-america-the-first-avenger-2011-poster.jpg", "backdrop": "assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1771, "ratings": {"rtCritics": 80, "rtCriticsLabel": "Certified Fresh", "rtAudience": 75, "rtAudienceLabel": "Upright", "imdb": 6.9, "imdbVotes": 993453, "tmdb": 7.012, "tmdbVotes": 23473}}, {"id": "movie-captain-america-the-winter-soldier-2014", "title": "Captain America: The Winter Soldier", "year": 2014, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action", "Adventure", "Science Fiction"], "overview": "After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026", "poster": "assets/movie-captain-america-the-winter-soldier-2014-poster.jpg", "backdrop": "assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 100402, "ratings": {"rtCritics": 90, "rtCriticsLabel": "Certified Fresh", "rtAudience": 92, "rtAudienceLabel": "Upright", "imdb": 7.7, "imdbVotes": 995514, "tmdb": 7.654, "tmdbVotes": 20688}}, {"id": "movie-careful-what-you-wish-for-2015", "title": "Careful What You Wish For", "year": 2015, "type": "movie", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Thriller"], "overview": "A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.", "poster": "assets/movie-careful-what-you-wish-for-2015-poster.jpg", "backdrop": "assets/movie-careful-what-you-wish-for-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 317744, "ratings": {"rtCritics": 17, "rtCriticsLabel": "Rotten", "rtAudience": 61, "rtAudienceLabel": "Upright", "imdb": 5.6, "imdbVotes": 9452, "tmdb": 6.129, "tmdbVotes": 446}}, {"id": "tv-12-monkeys-2015", "title": "12 Monkeys", "year": 2015, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Drama"], "overview": "The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026", "poster": "assets/tv-12-monkeys-2015-poster.jpg", "backdrop": "assets/tv-12-monkeys-2015-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 60948, "ratings": {"rtCritics": 88, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.441, "tmdbVotes": 1245}}, {"id": "tv-90-day-fiance-before-the-90-days-2017", "title": "90 Day Fianc\u00e9: Before the 90 Days", "year": 2017, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026", "poster": "assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg", "backdrop": "assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 73319, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.5, "tmdbVotes": 45}}, {"id": "tv-90-day-fiance-the-other-way-2019", "title": "90 Day Fianc\u00e9: The Other Way", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?", "poster": "assets/tv-90-day-fiance-the-other-way-2019-poster.jpg", "backdrop": "assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 90046, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 34}}, {"id": "tv-the-100-2014", "title": "The 100", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Sci-Fi & Fantasy", "Action & Adventure", "Drama"], "overview": "100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026", "poster": "assets/tv-the-100-2014-poster.jpg", "backdrop": "assets/tv-the-100-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 48866, "ratings": {"rtCritics": 93, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.878, "tmdbVotes": 8855}}, {"id": "tv-accused-2023", "title": "Accused", "year": 2023, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama"], "overview": "In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026", "poster": "assets/tv-accused-2023-poster.jpg", "backdrop": "assets/tv-accused-2023-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 202097, "ratings": {"rtCritics": 61, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.851, "tmdbVotes": 87}}, {"id": "tv-after-life-2019", "title": "After Life", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026", "poster": "assets/tv-after-life-2019-poster.jpg", "backdrop": "assets/tv-after-life-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 79410, "ratings": {"rtCritics": 72, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.902, "tmdbVotes": 1169}}, {"id": "tv-all-creatures-great-and-small-1978", "title": "All Creatures Great and Small", "year": 1978, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy", "Drama"], "overview": "The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.", "poster": "assets/tv-all-creatures-great-and-small-1978-poster.jpg", "backdrop": "assets/tv-all-creatures-great-and-small-1978-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 7406, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 50}}, {"id": "tv-american-housewife-2016", "title": "American Housewife", "year": 2016, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.", "poster": "assets/tv-american-housewife-2016-poster.jpg", "backdrop": "assets/tv-american-housewife-2016-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 67117, "ratings": {"rtCritics": null, "rtCriticsLabel": "Rotten", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.7, "tmdbVotes": 187}}, {"id": "tv-atlantis-2013", "title": "Atlantis", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy"], "overview": "A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026", "poster": "assets/tv-atlantis-2013-poster.jpg", "backdrop": "assets/tv-atlantis-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 47054, "ratings": {"rtCritics": 62, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.4, "tmdbVotes": 155}}, {"id": "tv-avatar-the-last-airbender-2024", "title": "Avatar: The Last Airbender", "year": 2024, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Action & Adventure", "Sci-Fi & Fantasy", "Drama"], "overview": "A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.", "poster": "assets/tv-avatar-the-last-airbender-2024-poster.jpg", "backdrop": "assets/tv-avatar-the-last-airbender-2024-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 82452, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.8, "tmdbVotes": 1406}}, {"id": "tv-avatar-seven-havens-2026", "title": "Avatar: Seven Havens", "year": 2026, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026", "poster": "assets/tv-avatar-seven-havens-2026-poster.jpg", "backdrop": "assets/tv-avatar-seven-havens-2026-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 284833, "ratings": {"rtCritics": 100, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.9, "tmdbVotes": 183}}, {"id": "tv-batman-the-brave-and-the-bold-2008", "title": "Batman: The Brave and the Bold", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Animation", "Action & Adventure", "Sci-Fi & Fantasy"], "overview": "The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.", "poster": "assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg", "backdrop": "assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 15804, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.6, "tmdbVotes": 295}}, {"id": "tv-beat-bobby-flay-2013", "title": "Beat Bobby Flay", "year": 2013, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Reality"], "overview": "Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026", "poster": "assets/tv-beat-bobby-flay-2013-poster.jpg", "backdrop": "assets/tv-beat-bobby-flay-2013-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 62481, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.5, "tmdbVotes": 31}}, {"id": "tv-benson-1979", "title": "Benson", "year": 1979, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A butler deals with life at the governor's mansion.", "poster": "assets/tv-benson-1979-poster.jpg", "backdrop": "assets/tv-benson-1979-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1849, "ratings": {"rtCritics": null, "rtCriticsLabel": null, "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7, "tmdbVotes": 46}}, {"id": "tv-black-rabbit-2025", "title": "Black Rabbit", "year": 2025, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.", "poster": "assets/tv-black-rabbit-2025-poster.jpg", "backdrop": "assets/tv-black-rabbit-2025-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 249039, "ratings": {"rtCritics": 66, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 6.983, "tmdbVotes": 240}}, {"id": "tv-black-ish-2014", "title": "black-ish", "year": 2014, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Comedy"], "overview": "A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.", "poster": "assets/tv-black-ish-2014-poster.jpg", "backdrop": "assets/tv-black-ish-2014-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 61381, "ratings": {"rtCritics": 92, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.1, "tmdbVotes": 352}}, {"id": "tv-breaking-bad-2008", "title": "Breaking Bad", "year": 2008, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Drama", "Crime"], "overview": "Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026", "poster": "assets/tv-breaking-bad-2008-poster.jpg", "backdrop": "assets/tv-breaking-bad-2008-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "center_crop", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 1396, "ratings": {"rtCritics": 96, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 8.959, "tmdbVotes": 19030}}, {"id": "tv-the-capture-2019", "title": "The Capture", "year": 2019, "type": "tv", "runtimeSeconds": null, "seasonCount": null, "communityRating": null, "criticRating": null, "genres": ["Crime", "Drama", "Mystery"], "overview": "When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026", "poster": "assets/tv-the-capture-2019-poster.jpg", "backdrop": "assets/tv-the-capture-2019-backdrop.jpg", "played": null, "playCount": null, "resumeSeconds": null, "progress": null, "addedAt": null, "lastPlayedAt": null, "availability": "not_here", "availabilityEvidence": "retained_profile_membership_and_native_request_ledger", "source": "materialized_personal_recommendation", "rank": null, "watchStateKnown": false, "artwork": {"posterKind": "poster", "backdropKind": "poster_crop"}, "availabilityIsSnapshot": true, "tmdbId": 93166, "ratings": {"rtCritics": 97, "rtCriticsLabel": "Fresh", "rtAudience": null, "rtAudienceLabel": null, "imdb": null, "imdbVotes": null, "tmdb": 7.634, "tmdbVotes": 361}}], "discoverySource": {"state": "materialized_snapshot", "rankKnown": false, "selection": "First 18 alphabetically indexed movie stubs and first 18 series stubs, not ranked feed order", "observedAt": "2026-11-04T20:42:12.030108+00:00"}}], "notes": {"real": ["Profile keys and library membership derive from deployed Conductarr configuration and viewer-scoped Jellyfin GET responses.", "Names, years, overviews, library ratings, genres, season-container counts and watch/resume data come from actual Jellyfin metadata.", "All local artwork is copied from Jellyfin; discovery posters are the real materialized Conductarr recommendation artwork.", "Discover contains 36 real materialized personal recommendations per profile, with profile-scoped availability from read-only retained membership/request state.", "Justin has no playable resume items. Mindel and Shared each have one. No progress or watched flags were invented."], "approximations": ["Default profile initials and display capitalization are frontend presentation choices; colours reuse the existing PWA palette, not live user appearance configuration.", "Conductarr catalogue and recommendation GET endpoints both returned 401. Authentication was not bypassed. SQLite contains no retained recommendation feed/candidate snapshot tables; the source keeps ranked feeds in memory. Discovery therefore reads existing per-profile Jellyfin recommendation stubs as metadata only.", "Discovery selection is 18 alphabetically indexed movie suggestions plus 18 series suggestions, not personal rank order; rank is null. Materialization age and original feed ordering are unknown.", "Discovery stub runtime is synthetic (two seconds), so runtime, season count, rating and watch state are null rather than presenting stub values as real media facts.", "93 selected identities lack original landscape art in the materialized metadata; their backdrop is a marked crop of the real poster, not an original film backdrop. Existing artwork is reused; new artwork is centre-cropped to the required dimensions.", "Jellyfin server-side resizing requests maxWidth/maxHeight/quality. When the result had the wrong aspect ratio, sequential single-thread ffmpeg center crops produce exact 300x450 and 1280x720 JPEGs.", "Series seasonCount is Jellyfin ChildCount, including season containers for specials when present.", "Discover availability is a retained profile-membership/request snapshot, not a fresh playback or acquisition guarantee. not_here means absent from that profile in retained state, not absent from the entire household."], "missing": ["Justin Movies has seven live viewer-scoped films, not 200. No titles are invented or borrowed across profile boundaries.", "Discovery community/critic ratings, reliable runtime/season counts, personal feed ranking/freshness and 93 original landscape backdrops are unavailable through the permitted read paths."], "unmetRequirements": ["At least 200 real profile-scoped Justin Movies"], "mustNotBeShown": ["Recs Movies", "Recs Shows", "Justin Movies", "Justin Shows", "Mindel Movies", "Mindel Shows", "Shared Movies", "Shared Shows", "DGiC", "Stored names padded with spaces", "Internal bucket and acquisition-instance names"], "privacy": ["Includes actual watched and resume history for Justin, Mindel and Shared independently; Shared is not a union.", "No Jellyfin item/user IDs, source paths, service URLs, credentials, request actor identities, or private hotlinks are included. tmdbId is a public external title identity.", "Only the seven configured playable libraries are selected. Extra observed DGiC views and all recommendation stubs are excluded from playable collections.", "No profile is assumed to be a child or age-restricted. Each playable list and episode shelf is limited to that profile\u2019s configured libraries."], "sourceReadPaths": ["GET /Users (allowlisted mapping only)", "GET /Users/{userId}/Views", "GET /Users/{userId}/Items (parent-scoped playable libraries and metadata-only recommendation libraries)", "GET /Users/{userId}/Items/Resume", "GET /Shows/NextUp?UserId=...", "GET /Items/{itemId}/Images/Primary", "GET /Items/{itemId}/Images/Backdrop/0", "Conductarr GET /catalogue/discover?page=1&media_type=movie (401)", "Conductarr GET /api/v1/recommendations/profiles/justin/feed (401)", "SQLite file URI mode=ro plus PRAGMA query_only=ON: catalogue_metadata, bucket_memberships, native_requests; sqlite_master for schema inspection"], "productionMutations": 0, "artwork": {"files": 632, "bytes": 35201423, "posterDimensions": [300, 450], "backdropDimensions": [1280, 720], "posterQualityRequested": 70, "backdropQualityRequested": 65, "posterDerivedBackdropCount": 93}, "consumerContract": ["profiles is an array in Justin/Mindel/Shared order. Every profile owns movies/shows/anime/resume/nextUp/recentlyAdded/discover item arrays; empty shelves are intentional.", "Item paths are relative to fixture.json. Resolve assets against the fixture directory, not against the outer prototype document.", "Use destinations for visible navigation. No raw library display/stored names belong in UI.", "Deduplicate library/resume/discover by stable local id or (type,tmdbId). Never treat a materialized recommendation as a playable library item.", "played=null and watchStateKnown=false mean unknown, not unwatched. Null ratings are missing, not zero; communityRating source is Jellyfin, not an asserted TMDB/IMDb score.", "availability values are in_library/requested/not_here. No requested examples were observed in the selected discover set; do not fabricate them.", "Render artwork.backdropKind=poster_crop as a poster-derived landscape; prefer the poster over pretending it is original landscape art.", "This static fixture preserves actual current collection sizes, viewer-scoped watch state and unknown discovery ranking; request and playback demonstrations must remain labelled simulations."]}} \ No newline at end of file diff --git a/wolfskin-design/src/test/resources/wolfskin-home-presses.json b/wolfskin-design/src/test/resources/wolfskin-home-presses.json deleted file mode 100644 index 700edd60..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-home-presses.json +++ /dev/null @@ -1,126 +0,0 @@ -[ - { - "goal": "Resume (Mindel, from launch)", - "keys": [ - "RIGHT", - "OK", - "OK" - ], - "presses": 3, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Play next (Justin has no resume)", - "keys": [ - "OK", - "OK" - ], - "presses": 2, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Round 3 \u00b7 Home hero \u2192 Trending (D2 split)", - "keys": [ - "OK", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN" - ], - "presses": 13, - "note": "12 Downs after the launch OK", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Round 3 \u00b7 Trending \u2192 its See all card (D3: 20 cards)", - "keys": [ - "OK", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT", - "RIGHT" - ], - "presses": 33, - "note": "20 Rights from Trending", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Round 3 \u00b7 deep Home row: Back to the nav, Down to the same card", - "keys": [ - "OK", - "DOWN", - "DOWN", - "DOWN", - "DOWN", - "RIGHT", - "RIGHT", - "BACK", - "DOWN" - ], - "presses": 9, - "note": "2: Back, Down", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - }, - { - "goal": "Open Discover", - "keys": [ - "OK", - "UP", - "RIGHT", - "OK" - ], - "presses": 4, - "note": "", - "start": "app launch: Who's watching with Justin focused (index.html:357)", - "replayed": "PASS", - "source": "index.html:2152-2174 PRESSES, replayed 2026-11-05" - } -] diff --git a/wolfskin-design/src/test/resources/wolfskin-library.json b/wolfskin-design/src/test/resources/wolfskin-library.json deleted file mode 100644 index 76617014..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-library.json +++ /dev/null @@ -1 +0,0 @@ -{"profiles":[{"key":"justin","movies":[{"id":"movie-the-girl-who-leapt-through-time-2006","title":"The Girl Who Leapt Through Time","year":2006,"type":"movie","runtimeSeconds":5886,"seasonCount":null,"communityRating":7.772,"criticRating":87,"genres":["Fantasy","Animation","Drama"],"overview":"When 17-year-old Makoto Konno gains the ability to 'leap' backwards through time, she immediately sets about improving her grades and preventing personal mishaps. However, she soon realises that changing the past\u2026","poster":"assets/movie-the-girl-who-leapt-through-time-2006-poster.jpg","backdrop":"assets/movie-the-girl-who-leapt-through-time-2006-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14069,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":76917,"tmdb":7.775,"tmdbVotes":2345}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.891,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-look-who-s-back-2015","title":"Look Who's Back","year":2015,"type":"movie","runtimeSeconds":6936,"seasonCount":null,"communityRating":6.793,"criticRating":null,"genres":["Comedy"],"overview":"When Adolf Hitler reawakens at the site of his former bunker in present-day Berlin, he is mistaken for a comedian and quickly becomes a media phenomenon.","poster":"assets/movie-look-who-s-back-2015-poster.jpg","backdrop":"assets/movie-look-who-s-back-2015-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":318917,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":54672,"tmdb":6.796,"tmdbVotes":1990}},{"id":"movie-memories-1995","title":"Memories","year":1995,"type":"movie","runtimeSeconds":6805,"seasonCount":null,"communityRating":7.465,"criticRating":null,"genres":["Fantasy","Animation","Science Fiction"],"overview":"Three back-to-back anime films by three different directors make up this sci-fi trilogy three years in the making.","poster":"assets/movie-memories-1995-poster.jpg","backdrop":"assets/movie-memories-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-30T21:36:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":42994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":26638,"tmdb":7.459,"tmdbVotes":622}},{"id":"movie-reincarnated-2012","title":"Reincarnated","year":2012,"type":"movie","runtimeSeconds":5759,"seasonCount":null,"communityRating":6.145,"criticRating":28,"genres":["Music","Documentary"],"overview":"Evolution as an artist is often times what separates legends from the more mundane. After being heavily influenced by his experience in Jamaica \u2013 and his subsequent name change from Snoop Dogg to Snoop Lion \u2013 the LBC\u2026","poster":"assets/movie-reincarnated-2012-poster.jpg","backdrop":"assets/movie-reincarnated-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":123431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":2563,"tmdb":6.1,"tmdbVotes":69}},{"id":"movie-shipwrecked-nightmare-at-sea-2026","title":"Shipwrecked: Nightmare at Sea","year":2026,"type":"movie","runtimeSeconds":5385,"seasonCount":null,"communityRating":7.181,"criticRating":null,"genres":["Documentary"],"overview":"Never-before-seen footage and survivor accounts trace the 2012 shipwreck of a luxury cruise and the disaster that ensued, in this immersive documentary.","poster":"assets/movie-shipwrecked-nightmare-at-sea-2026-poster.jpg","backdrop":"assets/movie-shipwrecked-nightmare-at-sea-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T20:11:43.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1715492,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":6.5,"imdbVotes":5080,"tmdb":7.172,"tmdbVotes":87}},{"id":"movie-steins-gate-the-movie-load-region-of-deja-vu-2013","title":"Steins;Gate: The Movie - Load Region of D\u00e9j\u00e0 Vu","year":2013,"type":"movie","runtimeSeconds":5403,"seasonCount":null,"communityRating":7.8,"criticRating":null,"genres":["Animation","Mystery","Sci-Fi"],"overview":"After the events of the anime, Rintarou begins to feel the repercussions of extensive time travel, and eventually completely fades from reality. Kurisu, being the only companion to remember him, now must find a way\u2026","poster":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-poster.jpg","backdrop":"assets/movie-steins-gate-the-movie-load-region-of-deja-vu-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T14:55:54.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","artwork":{"posterKind":"poster","backdropKind":"backdrop"}}],"shows":[{"id":"tv-andor-2022","title":"Andor","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.293,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"In an era filled with danger, deception and intrigue, Cassian Andor will discover the difference he can make in the struggle against the tyrannical Galactic Empire. He embarks on a path that is destined to turn him\u2026","poster":"assets/tv-andor-2022-poster.jpg","backdrop":"assets/tv-andor-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0432996Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.321,"tmdbVotes":2312}},{"id":"tv-the-boys-2019","title":"The Boys","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.443,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"A group of vigilantes known informally as \u201cThe Boys\u201d set out to take down corrupt superheroes with no more than blue-collar grit and a willingness to fight dirty.","poster":"assets/tv-the-boys-2019-poster.jpg","backdrop":"assets/tv-the-boys-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0410131Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":13628}},{"id":"tv-californication-2007","title":"Californication","year":2007,"type":"tv","runtimeSeconds":1680,"seasonCount":7,"communityRating":7.632,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A self-loathing, alcoholic writer attempts to repair his damaged relationships with his daughter and her mother while combating sex addiction, a budding drug problem, and the seeming inability to avoid making bad\u2026","poster":"assets/tv-californication-2007-poster.jpg","backdrop":"assets/tv-californication-2007-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0428037Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1215,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.62,"tmdbVotes":1406}},{"id":"tv-firefly-2002","title":"Firefly","year":2002,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.347,"criticRating":null,"genres":["Drama","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In the year 2517, the universe is a vast frontier where lawlessness reigns and adventure awaits! Join Captain Malcolm Reynolds and his ragtag crew aboard the spaceship Serenity as they navigate the fringes of\u2026","poster":"assets/tv-firefly-2002-poster.jpg","backdrop":"assets/tv-firefly-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:40:08.4202369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1437,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":77,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.351,"tmdbVotes":2550}},{"id":"tv-fleabag-2016","title":"Fleabag","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.278,"criticRating":null,"genres":["Comedy","Drama"],"overview":"A portrait into the mind of a dry-witted, sexual, angry, porn-watching, grief-riddled woman, trying to make sense of the world. As she hurls herself headlong at modern living, Fleabag is thrown roughly up against the\u2026","poster":"assets/tv-fleabag-2016-poster.jpg","backdrop":"assets/tv-fleabag-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:42:25.4848214Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":67070,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":1993}},{"id":"tv-gravity-falls-2012","title":"Gravity Falls","year":2012,"type":"tv","runtimeSeconds":1320,"seasonCount":2,"communityRating":8.619,"criticRating":null,"genres":["Action & Adventure","Animation","Comedy"],"overview":"Twin brother and sister Dipper and Mabel Pines are in for an unexpected adventure when they spend the summer helping their great uncle Stan run a tourist trap in the mysterious town of Gravity Falls, Oregon.","poster":"assets/tv-gravity-falls-2012-poster.jpg","backdrop":"assets/tv-gravity-falls-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0435827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40075,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.624,"tmdbVotes":3647}},{"id":"tv-invincible-2021","title":"INVINCIBLE","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.638,"criticRating":null,"genres":["Animation","Drama","Sci-Fi & Fantasy"],"overview":"Mark Grayson is a normal teenager except for the fact that his father is the most powerful superhero on the planet. Shortly after his seventeenth birthday, Mark begins to develop powers of his own and enters into his\u2026","poster":"assets/tv-invincible-2021-poster.jpg","backdrop":"assets/tv-invincible-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.040159Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95557,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":6155}},{"id":"tv-the-legend-of-korra-2012","title":"The Legend of Korra","year":2012,"type":"tv","runtimeSeconds":1380,"seasonCount":4,"communityRating":8.2,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Avatar Korra, a headstrong, rebellious, feisty young woman who continually challenges and breaks with tradition, is on her quest to become a fully realized Avatar. In this story, the Avatar struggles to find balance\u2026","poster":"assets/tv-the-legend-of-korra-2012-poster.jpg","backdrop":"assets/tv-the-legend-of-korra-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T00:19:23.2494945Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33880,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.221,"tmdbVotes":2482}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.205,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T11:31:20.8818757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-norsemen-2016","title":"Norsemen","year":2016,"type":"tv","runtimeSeconds":1800,"seasonCount":3,"communityRating":7.177,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Norsemen is an epic and humorous drama series set in the Viking Age. The residents of an 8th-century Viking village experience political rivalry, social change and innovations that upend their culture and way of life.","poster":"assets/tv-norsemen-2016-poster.jpg","backdrop":"assets/tv-norsemen-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.034182Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":68126,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":233}},{"id":"tv-rick-and-morty-2013","title":"Rick and Morty","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":9,"communityRating":8.679,"criticRating":null,"genres":["Animation","Comedy","Sci-Fi & Fantasy"],"overview":"Follows a sociopathic genius scientist who drags his inherently timid grandson on adventures across the universe.","poster":"assets/tv-rick-and-morty-2013-poster.jpg","backdrop":"assets/tv-rick-and-morty-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0419581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60625,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":11548}},{"id":"tv-secret-level-2024","title":"Secret Level","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.411,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"Adult animated series of original short stories which are set within the worlds of beloved video games. Each episode serves as a gateway to a new adventure, unlocking exciting worlds from beloved gaming classics and\u2026","poster":"assets/tv-secret-level-2024-poster.jpg","backdrop":"assets/tv-secret-level-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0336417Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":261579,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.392,"tmdbVotes":612}},{"id":"tv-silo-2023","title":"Silo","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.178,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"In a ruined and toxic future, thousands live in a giant silo deep underground. After its sheriff breaks a cardinal rule and residents die mysteriously, engineer Juliette starts to uncover shocking secrets and the\u2026","poster":"assets/tv-silo-2023-poster.jpg","backdrop":"assets/tv-silo-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0346526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125988,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.232,"tmdbVotes":2881}},{"id":"tv-south-park-1997","title":"South Park","year":1997,"type":"tv","runtimeSeconds":null,"seasonCount":29,"communityRating":8.336,"criticRating":null,"genres":["Animation","Comedy"],"overview":"Follow the misadventures of four irreverent grade-schoolers in the quiet, dysfunctional town of South Park, Colorado.","poster":"assets/tv-south-park-1997-poster.jpg","backdrop":"assets/tv-south-park-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0456124Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2190,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.332,"tmdbVotes":5250}}],"anime":[{"id":"tv-bleach-2004","title":"Bleach","year":2004,"type":"tv","runtimeSeconds":1440,"seasonCount":17,"communityRating":8.363,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"For as long as he can remember, Ichigo Kurosaki has been able to see ghosts. But when he meets Rukia, a Soul Reaper who battles evil spirits known as Hollows, he finds his life is changed forever. Now, with a\u2026","poster":"assets/tv-bleach-2004-poster.jpg","backdrop":"assets/tv-bleach-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0359827Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":2331}},{"id":"tv-chainsaw-man-2022","title":"Chainsaw Man","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.515,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Denji has a simple dream\u2014to live a happy and peaceful life, spending time with a girl he likes. This is a far cry from reality, however, as Denji is forced by the yakuza into killing devils in order to pay off his\u2026","poster":"assets/tv-chainsaw-man-2022-poster.jpg","backdrop":"assets/tv-chainsaw-man-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2095501Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114410,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.508,"tmdbVotes":2364}},{"id":"tv-cyberpunk-edgerunners-2022","title":"Cyberpunk: Edgerunners","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.466,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a dystopia riddled with corruption and cybernetic implants, a talented but reckless street kid strives to become a mercenary outlaw \u2014 an edgerunner.","poster":"assets/tv-cyberpunk-edgerunners-2022-poster.jpg","backdrop":"assets/tv-cyberpunk-edgerunners-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2093107Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105248,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.507,"tmdbVotes":2132}},{"id":"tv-dan-da-dan-2024","title":"Dan Da Dan","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.538,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"In a bet to prove whether ghosts or aliens exist, two high schoolers face terrifying paranormal threats, gain superpowers and maybe even fall in love?!","poster":"assets/tv-dan-da-dan-2024-poster.jpg","backdrop":"assets/tv-dan-da-dan-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2652252Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.517,"tmdbVotes":986}},{"id":"tv-dr-stone-2019","title":"Dr. STONE","year":2019,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.541,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he\u2019s not alone! His\u2026","poster":"assets/tv-dr-stone-2019-poster.jpg","backdrop":"assets/tv-dr-stone-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2091579Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86031,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.53,"tmdbVotes":1814}},{"id":"tv-frieren-beyond-journey-s-end-2023","title":"Frieren: Beyond Journey's End","year":2023,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.803,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"After the party of heroes defeated the Demon King, they restored peace to the land and returned to lives of solitude. Generations pass, and the elven mage Frieren comes face to face with humanity\u2019s mortality. She\u2026","poster":"assets/tv-frieren-beyond-journey-s-end-2023-poster.jpg","backdrop":"assets/tv-frieren-beyond-journey-s-end-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2094435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":209867,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.826,"tmdbVotes":1016}},{"id":"tv-fullmetal-alchemist-brotherhood-2009","title":"Fullmetal Alchemist: Brotherhood","year":2009,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.7,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Disregard for alchemy\u2019s laws ripped half of Edward Elric\u2019s limbs from his body and left his brother Alphonse\u2019s soul clinging to a suit of armor. To restore what was lost, the brothers seek the Philosopher\u2019s Stone.\u2026","poster":"assets/tv-fullmetal-alchemist-brotherhood-2009-poster.jpg","backdrop":"assets/tv-fullmetal-alchemist-brotherhood-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.3573939Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":31911,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":2633}},{"id":"tv-jujutsu-kaisen-2020","title":"JUJUTSU KAISEN","year":2020,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.583,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Yuji Itadori is a boy with tremendous physical strength, though he lives a completely ordinary high school life. One day, to save a classmate who has been attacked by curses, he eats the finger of Ryomen Sukuna,\u2026","poster":"assets/tv-jujutsu-kaisen-2020-poster.jpg","backdrop":"assets/tv-jujutsu-kaisen-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207573Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.574,"tmdbVotes":4735}},{"id":"tv-kaiju-no-8-2024","title":"Kaiju No. 8","year":2024,"type":"tv","runtimeSeconds":1440,"seasonCount":2,"communityRating":8.436,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world plagued by creatures known as Kaiju, Kafka Hibino aspired to enlist in The Defense Force. He makes a promise to enlist with his childhood friend, Mina Ashiro. Soon, life takes them in separate ways. While\u2026","poster":"assets/tv-kaiju-no-8-2024-poster.jpg","backdrop":"assets/tv-kaiju-no-8-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2646566Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207468,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":836}},{"id":"tv-my-hero-academia-2016","title":"My Hero Academia","year":2016,"type":"tv","runtimeSeconds":1440,"seasonCount":8,"communityRating":8.607,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"After he saves a bully from a Villain, a normal student is granted a superpower that allows him to attend a high school training academy for Heroes.","poster":"assets/tv-my-hero-academia-2016-poster.jpg","backdrop":"assets/tv-my-hero-academia-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2089315Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65930,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":5426}},{"id":"tv-neon-genesis-evangelion-1995","title":"Neon Genesis Evangelion","year":1995,"type":"tv","runtimeSeconds":1500,"seasonCount":1,"communityRating":8.568,"criticRating":null,"genres":["Sci-Fi & Fantasy","Animation","Drama"],"overview":"At the turn of the century, the Angels returned to Earth, seeking to wipe out humanity in an apocalyptic fury. Devastated, mankind's last remnants moved underground to wait for the day when the Angels would come back\u2026","poster":"assets/tv-neon-genesis-evangelion-1995-poster.jpg","backdrop":"assets/tv-neon-genesis-evangelion-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0340629Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":890,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.56,"tmdbVotes":2205}},{"id":"tv-record-of-ragnarok-2021","title":"Record of Ragnarok","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.402,"criticRating":null,"genres":["Action & Adventure","Animation","Sci-Fi & Fantasy"],"overview":"Before eradicating humankind from the world, the gods give them one last chance to prove themselves worthy of survival. Let the Ragnarok battles begin.","poster":"assets/tv-record-of-ragnarok-2021-poster.jpg","backdrop":"assets/tv-record-of-ragnarok-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.2085766Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":114868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.403,"tmdbVotes":1929}},{"id":"tv-sakamoto-days-2025","title":"SAKAMOTO DAYS","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.071,"criticRating":null,"genres":["Animation","Comedy","Action & Adventure"],"overview":"Once the greatest hitman of all, Taro Sakamoto retired in the name of love. But when his past catches up, he must fight to protect his beloved family.","poster":"assets/tv-sakamoto-days-2025-poster.jpg","backdrop":"assets/tv-sakamoto-days-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.266066Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.057,"tmdbVotes":375}},{"id":"tv-sentenced-to-be-a-hero-2025","title":"Sentenced to Be a Hero","year":2025,"type":"tv","runtimeSeconds":1440,"seasonCount":1,"communityRating":8.338,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy","Action & Adventure"],"overview":"In a world where heroism is a punishment, Xylo Forbartz, a condemned goddess killer, battles endless hordes of monstrous abominations as part of Penal Hero Unit 9004. Death is no escape, only a cycle of resurrection\u2026","poster":"assets/tv-sentenced-to-be-a-hero-2025-poster.jpg","backdrop":"assets/tv-sentenced-to-be-a-hero-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2666791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":249907,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.244,"tmdbVotes":129}},{"id":"tv-spy-x-family-2022","title":"SPY x FAMILY","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.506,"criticRating":null,"genres":["Animation","Action & Adventure","Comedy"],"overview":"A spy, an assassin and a telepath come together to pose as a family, each for their own reasons, while hiding their true identities from each other.","poster":"assets/tv-spy-x-family-2022-poster.jpg","backdrop":"assets/tv-spy-x-family-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-22T10:31:16.2670364Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":120089,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":2382}},{"id":"tv-the-tatami-galaxy-2010","title":"The Tatami Galaxy","year":2010,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.271,"criticRating":null,"genres":["Animation","Comedy","Drama"],"overview":"When a college dropout stops for a late night bite at a mysterious ramen stand, he crosses paths with a self-proclaimed deity of matrimony. This bizarre meeting sends the young man hurtling through a horrifying\u2026","poster":"assets/tv-the-tatami-galaxy-2010-poster.jpg","backdrop":"assets/tv-the-tatami-galaxy-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-18T05:52:51.0023256Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":36243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":152}},{"id":"tv-vinland-saga-2019","title":"Vinland Saga","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":8.527,"criticRating":null,"genres":["Animation","War & Politics","Action & Adventure"],"overview":"For a thousand years, the Vikings have made quite a name and reputation for themselves as the strongest families with a thirst for violence. Thorfinn, the son of one of the Vikings' greatest warriors, spends his\u2026","poster":"assets/tv-vinland-saga-2019-poster.jpg","backdrop":"assets/tv-vinland-saga-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T22:28:13.207389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.509,"tmdbVotes":1064}},{"id":"tv-yu-yu-hakusho-1992","title":"Yu Yu Hakusho","year":1992,"type":"tv","runtimeSeconds":1440,"seasonCount":4,"communityRating":8.5,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"After dying to save a boy, delinquent tough guy Yusuke Urameshi is granted another chance at life by redeeming himself as a \"Spirit Detective.\"","poster":"assets/tv-yu-yu-hakusho-1992-poster.jpg","backdrop":"assets/tv-yu-yu-hakusho-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T21:46:01.5733049Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":30669,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.475,"tmdbVotes":519}}],"discover":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]},{"key":"mindel","movies":[{"id":"movie-chicago-2002","title":"Chicago","year":2002,"type":"movie","runtimeSeconds":6808,"seasonCount":null,"communityRating":7.131,"criticRating":87,"genres":["Comedy","Crime","Drama"],"overview":"Murderesses Velma Kelly and Roxie Hart find themselves on death row together and fight for the fame that will keep them from the gallows in 1920s Chicago.","poster":"assets/movie-chicago-2002-poster.jpg","backdrop":"assets/movie-chicago-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":259272,"tmdb":7.135,"tmdbVotes":3025}},{"id":"movie-coherence-2014","title":"Coherence","year":2014,"type":"movie","runtimeSeconds":5286,"seasonCount":null,"communityRating":7.21,"criticRating":89,"genres":["Thriller","Science Fiction"],"overview":"Four couples gather for dinner the night a mysterious and powerful comet passes overhead.","poster":"assets/movie-coherence-2014-poster.jpg","backdrop":"assets/movie-coherence-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2017-08-12T23:49:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":220289,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":180354,"tmdb":7.208,"tmdbVotes":3702}},{"id":"movie-the-killing-of-a-sacred-deer-2017","title":"The Killing of a Sacred Deer","year":2017,"type":"movie","runtimeSeconds":7262,"seasonCount":null,"communityRating":7.003,"criticRating":79,"genres":["Drama","Thriller","Mystery"],"overview":"Dr. Steven Murphy is a renowned cardiovascular surgeon who presides over a spotless household with his wife and two children. Lurking at the margins of his idyllic suburban existence is Martin, a fatherless teen who\u2026","poster":"assets/movie-the-killing-of-a-sacred-deer-2017-poster.jpg","backdrop":"assets/movie-the-killing-of-a-sacred-deer-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:49:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":399057,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":219488,"tmdb":7.001,"tmdbVotes":4640}},{"id":"movie-night-is-short-walk-on-girl-2017","title":"Night Is Short, Walk On Girl","year":2017,"type":"movie","runtimeSeconds":5548,"seasonCount":null,"communityRating":7.421,"criticRating":90,"genres":["Comedy","Romance","Animation"],"overview":"As a group of university students go out for a night on the town, a sophomore known only as \"The Girl with Black Hair\" experiences a series of surreal encounters with the local nightlife \u2013 all the while unaware of\u2026","poster":"assets/movie-night-is-short-walk-on-girl-2017-poster.jpg","backdrop":"assets/movie-night-is-short-walk-on-girl-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":430214,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":7640,"tmdb":7.4,"tmdbVotes":267}},{"id":"movie-thelma-louise-1991","title":"Thelma & Louise","year":1991,"type":"movie","runtimeSeconds":7786,"seasonCount":null,"communityRating":7.596,"criticRating":87,"genres":["Drama","Crime","Adventure"],"overview":"Taking a break from their dreary lives, close friends Thelma and Louise embark on a short weekend trip that ends in unforeseen incriminating circumstances. As fugitives, both women rediscover the strength of their\u2026","poster":"assets/movie-thelma-louise-1991-poster.jpg","backdrop":"assets/movie-thelma-louise-1991-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1541,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":197947,"tmdb":7.594,"tmdbVotes":4063}},{"id":"movie-vicky-cristina-barcelona-2008","title":"Vicky Cristina Barcelona","year":2008,"type":"movie","runtimeSeconds":5804,"seasonCount":null,"communityRating":6.798,"criticRating":80,"genres":["Drama","Romance"],"overview":"Two girlfriends on a summer holiday in Spain become enamored with the same painter, unaware that his ex-wife, with whom he has a tempestuous relationship, is about to re-enter the picture.","poster":"assets/movie-vicky-cristina-barcelona-2008-poster.jpg","backdrop":"assets/movie-vicky-cristina-barcelona-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:34.0000000Z","lastPlayedAt":"2026-08-11T20:12:57.3064089Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5038,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":74,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":283286,"tmdb":6.797,"tmdbVotes":4164}}],"shows":[{"id":"tv-all-creatures-great-small-2020","title":"All Creatures Great & Small","year":2020,"type":"tv","runtimeSeconds":3000,"seasonCount":7,"communityRating":8.2,"criticRating":null,"genres":["Drama","Comedy"],"overview":"The heartwarming and humorous adventures of a young country vet in the Yorkshire Dales in the 1930s. A remake of the 1978 series.","poster":"assets/tv-all-creatures-great-small-2020-poster.jpg","backdrop":"assets/tv-all-creatures-great-small-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:13:03.4266156Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":108255,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.2,"tmdbVotes":168}},{"id":"tv-back-to-life-2019","title":"Back to Life","year":2019,"type":"tv","runtimeSeconds":1500,"seasonCount":2,"communityRating":7.367,"criticRating":null,"genres":["Comedy","Drama"],"overview":"When Miri Matteson returns home after eighteen years, can she integrate back into her old life? With a terrible event from her past hanging over her, it won\u2019t be easy.","poster":"assets/tv-back-to-life-2019-poster.jpg","backdrop":"assets/tv-back-to-life-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0688758Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":88510,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":65}},{"id":"tv-big-boys-2022","title":"Big Boys","year":2022,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":7.6,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Two boys from very different ends of the 'spectrum of masculinity' become best friends at Brent University freshers' week; in their first year of uni, they explore, experiment and try to discover themselves, helping\u2026","poster":"assets/tv-big-boys-2022-poster.jpg","backdrop":"assets/tv-big-boys-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2156837Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":202851,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":49}},{"id":"tv-call-the-midwife-2012","title":"Call the Midwife","year":2012,"type":"tv","runtimeSeconds":3120,"seasonCount":15,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Drama following the lives of a group of midwives working in the poverty-stricken East End of London during the 1950s, based on the best-selling memoirs of Jennifer Worth.","poster":"assets/tv-call-the-midwife-2012-poster.jpg","backdrop":"assets/tv-call-the-midwife-2012-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0377216Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":39793,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.577,"tmdbVotes":267}},{"id":"tv-derry-girls-2018","title":"Derry Girls","year":2018,"type":"tv","runtimeSeconds":1380,"seasonCount":3,"communityRating":8.144,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Amidst the political conflict of Northern Ireland in the 1990s, five secondary school students square off with the universal challenges of being a teenager.","poster":"assets/tv-derry-girls-2018-poster.jpg","backdrop":"assets/tv-derry-girls-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-02T20:01:55.8955537Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":76148,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.138,"tmdbVotes":454}},{"id":"tv-desperate-housewives-2004","title":"Desperate Housewives","year":2004,"type":"tv","runtimeSeconds":2520,"seasonCount":8,"communityRating":7.896,"criticRating":null,"genres":["Mystery","Drama","Comedy"],"overview":"Looking down on her friends and family isn't a way of life for Mary Alice Young... it's a way of death. One day, in her perfect house, in the loveliest of suburbs, Mary Alice ended it all. Now she's taking us into\u2026","poster":"assets/tv-desperate-housewives-2004-poster.jpg","backdrop":"assets/tv-desperate-housewives-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-29T20:56:40.3505596Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":693,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.896,"tmdbVotes":2171}},{"id":"tv-the-diplomat-2023","title":"The Diplomat","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.569,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"Amid an international crisis, a US diplomat contends with her high-profile job as ambassador to the UK and her strained marriage to a political star.","poster":"assets/tv-the-diplomat-2023-poster.jpg","backdrop":"assets/tv-the-diplomat-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640348Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":203857,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.652,"tmdbVotes":521}},{"id":"tv-the-durrells-2016","title":"The Durrells","year":2016,"type":"tv","runtimeSeconds":2760,"seasonCount":4,"communityRating":7.402,"criticRating":null,"genres":["Drama","Comedy","Family"],"overview":"In 1935, financially strapped widow Louisa Durrell, whose life has fallen apart, decides to move from England, with her four children (three sons, one daughter), to the island of Corfu, Greece. Once there, the family\u2026","poster":"assets/tv-the-durrells-2016-poster.jpg","backdrop":"assets/tv-the-durrells-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0619436Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":66134,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":127}},{"id":"tv-etoile-2025","title":"\u00c9toile","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.197,"criticRating":null,"genres":["Comedy","Drama"],"overview":"In an ambitious gambit to save their storied institutions, two world-renowned ballet companies in New York City and Paris swap their most talented stars.","poster":"assets/tv-etoile-2025-poster.jpg","backdrop":"assets/tv-etoile-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601383Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225383,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":40}},{"id":"tv-gilmore-girls-2000","title":"Gilmore Girls","year":2000,"type":"tv","runtimeSeconds":2640,"seasonCount":7,"communityRating":7.893,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Set in the charming town of Stars Hollow, Connecticut, the series follows the captivating lives of Lorelai and Rory Gilmore, a mother/daughter pair who have a relationship most people only dream of.","poster":"assets/tv-gilmore-girls-2000-poster.jpg","backdrop":"assets/tv-gilmore-girls-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0631035Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4586,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":1339}},{"id":"tv-ginny-georgia-2021","title":"Ginny & Georgia","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":8.026,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Free-spirited Georgia and her two kids, Ginny and Austin, move north in search of a fresh start but find that the road to new beginnings can be bumpy.","poster":"assets/tv-ginny-georgia-2021-poster.jpg","backdrop":"assets/tv-ginny-georgia-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0694828Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117581,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":1840}},{"id":"tv-hellcats-2010","title":"Hellcats","year":2010,"type":"tv","runtimeSeconds":2460,"seasonCount":1,"communityRating":7.5,"criticRating":null,"genres":["Drama"],"overview":"Pre-law student Marti Perkins' world is flipped upside down when she loses her scholarship, and realizes the only way she can stay in school is by reigniting her dormant teen gymnastic skills to win a place on Lancer\u2026","poster":"assets/tv-hellcats-2010-poster.jpg","backdrop":"assets/tv-hellcats-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T20:39:02.7719318Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":32504,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":116}},{"id":"tv-high-potential-2024","title":"High Potential","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.201,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"Morgan is a single mom with an exceptional mind, whose unconventional knack for solving crimes leads to an unusual and unstoppable partnership with a by-the-book seasoned detective.","poster":"assets/tv-high-potential-2024-poster.jpg","backdrop":"assets/tv-high-potential-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0686091Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":226637,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.3,"tmdbVotes":707}},{"id":"tv-how-to-get-to-heaven-from-belfast-2026","title":"How to Get to Heaven from Belfast","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.027,"criticRating":null,"genres":["Mystery","Comedy","Drama"],"overview":"Three lifelong pals embark on a chaotic quest to solve the mystery of their old friend's suspicious death and keep their own dark secret under wraps.","poster":"assets/tv-how-to-get-to-heaven-from-belfast-2026-poster.jpg","backdrop":"assets/tv-how-to-get-to-heaven-from-belfast-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0683192Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":233079,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.063,"tmdbVotes":95}},{"id":"tv-the-hunting-wives-2025","title":"The Hunting Wives","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.932,"criticRating":null,"genres":["Drama","Mystery"],"overview":"Sophie trades New England for East Texas and falls into a wealthy socialite's magnetic orbit \u2014 where a clique of housewives hide deadly secrets.","poster":"assets/tv-the-hunting-wives-2025-poster.jpg","backdrop":"assets/tv-the-hunting-wives-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:46.3227892Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":244447,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.964,"tmdbVotes":112}},{"id":"tv-the-lost-flowers-of-alice-hart-2023","title":"The Lost Flowers of Alice Hart","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.634,"criticRating":null,"genres":["Drama","Mystery"],"overview":"After losing her parents in a mysterious fire, nine-year-old Alice Hart is raised by her grandmother June on a flower farm where she learns there are secrets within secrets. But years on, an unearthed betrayal sees\u2026","poster":"assets/tv-the-lost-flowers-of-alice-hart-2023-poster.jpg","backdrop":"assets/tv-the-lost-flowers-of-alice-hart-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0607236Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":125816,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.64,"tmdbVotes":161}},{"id":"tv-love-is-blind-2020","title":"Love Is Blind","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":11,"communityRating":6.261,"criticRating":null,"genres":["Reality"],"overview":"Nick and Vanessa Lachey host this social experiment where single men and women look for love and get engaged, all before meeting in person.","poster":"assets/tv-love-is-blind-2020-poster.jpg","backdrop":"assets/tv-love-is-blind-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T17:11:08.2447226Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":99353,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.233,"tmdbVotes":118}},{"id":"tv-maximum-pleasure-guaranteed-2026","title":"Maximum Pleasure Guaranteed","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":6.676,"criticRating":null,"genres":["Comedy"],"overview":"A newly divorced mom falls down a dangerous rabbit hole of blackmail, murder, and youth soccer.","poster":"assets/tv-maximum-pleasure-guaranteed-2026-poster.jpg","backdrop":"assets/tv-maximum-pleasure-guaranteed-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-07T21:04:02.8277793Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":285404,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.799,"tmdbVotes":92}},{"id":"tv-minx-2022","title":"Minx","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.833,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An earnest young feminist joins forces with a low-rent publisher to make the world's first erotic magazine for women.","poster":"assets/tv-minx-2022-poster.jpg","backdrop":"assets/tv-minx-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0653613Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":118303,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":123}},{"id":"tv-offspring-2010","title":"Offspring","year":2010,"type":"tv","runtimeSeconds":3600,"seasonCount":7,"communityRating":6.9,"criticRating":null,"genres":["Drama"],"overview":"An exuberant drama set in Melbourne's Fitzroy, centering on Nina Proudman and her struggle to deal with her fabulously messy family, her hunt for a decent love life and her tendency to overthink and fly off into fantasy.","poster":"assets/tv-offspring-2010-poster.jpg","backdrop":"assets/tv-offspring-2010-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0664581Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":33155,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":27}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.398,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:25:58.2163301Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-outlander-blood-of-my-blood-2025","title":"Outlander: Blood of My Blood","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.542,"criticRating":null,"genres":["Drama"],"overview":"From the battlefields of World War I to the rugged Highlands of 18th century Scotland, two fated couples must defy the forces that seek to tear them apart, unfolding in surprising and unforeseen ways in this romantic\u2026","poster":"assets/tv-outlander-blood-of-my-blood-2025-poster.jpg","backdrop":"assets/tv-outlander-blood-of-my-blood-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0691373Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":207484,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.738,"tmdbVotes":124}},{"id":"tv-rivals-2024","title":"Rivals","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.44,"criticRating":null,"genres":["Drama"],"overview":"It is 1986 and Britain is booming. \"Rivals\" dives headfirst into the cutthroat world of TV where hair-dos are big and ambitions are even bigger. Deals are brokered in boardrooms, as well as bedrooms. Nobody can be\u2026","poster":"assets/tv-rivals-2024-poster.jpg","backdrop":"assets/tv-rivals-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645815Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":208921,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":93}},{"id":"tv-rooster-2026","title":"Rooster","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.431,"criticRating":null,"genres":["Comedy"],"overview":"On a college campus, an author navigates a complicated relationship with his daughter.","poster":"assets/tv-rooster-2026-poster.jpg","backdrop":"assets/tv-rooster-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.063389Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":254953,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.423,"tmdbVotes":144}},{"id":"tv-sex-and-the-city-1998","title":"Sex and the City","year":1998,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.365,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Based on the bestselling book by Candace Bushnell, Sex and the City tells the story of four best friends, all single and in their late thirties, as they pursue their careers and talk about their sex lives, all while\u2026","poster":"assets/tv-sex-and-the-city-1998-poster.jpg","backdrop":"assets/tv-sex-and-the-city-1998-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0679324Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":105,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.351,"tmdbVotes":2220}},{"id":"tv-skymed-2022","title":"SkyMed","year":2022,"type":"tv","runtimeSeconds":2640,"seasonCount":4,"communityRating":6.665,"criticRating":null,"genres":["Drama"],"overview":"Life, death and drama at 20,000 feet. The series weaves together intense character journeys and high-stakes medical rescues, as we follow the triumphs, heartbreaks and tribulations of budding nurses and pilots flying\u2026","poster":"assets/tv-skymed-2022-poster.jpg","backdrop":"assets/tv-skymed-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-22T19:44:29.2431354Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":133908,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.699,"tmdbVotes":98}},{"id":"tv-so-you-think-you-can-dance-2008","title":"So You Think You Can Dance","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":9.8,"criticRating":null,"genres":["Family","Reality"],"overview":"So You Think You Can Dance is a joint Belgian-Dutch dance competition show broadcast on RTL 5 and vtm television stations. The first season was broadcast only in the Netherlands, whereas starting with season 2, the\u2026","poster":"assets/tv-so-you-think-you-can-dance-2008-poster.jpg","backdrop":"assets/tv-so-you-think-you-can-dance-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-23T11:11:35.4124418Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":40231,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":9.8,"tmdbVotes":4}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.597,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T02:45:52.8742674Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.431,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T20:31:47.4592756Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}}]},{"key":"shared","movies":[{"id":"movie-american-gangster-2007","title":"American Gangster","year":2007,"type":"movie","runtimeSeconds":10546,"seasonCount":null,"communityRating":7.595,"criticRating":81,"genres":["Drama","Crime"],"overview":"Loosely based on the criminal career of Frank Lucas, a gangster from La Grange, North Carolina, who smuggled heroin into the United States on American service planes returning from the Vietnam War, before being\u2026","poster":"assets/movie-american-gangster-2007-poster.jpg","backdrop":"assets/movie-american-gangster-2007-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-23T10:22:36.0000000Z","lastPlayedAt":"2026-08-30T21:05:39.7952451Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4982,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":495656,"tmdb":7.599,"tmdbVotes":6350}},{"id":"movie-annie-hall-1977","title":"Annie Hall","year":1977,"type":"movie","runtimeSeconds":5604,"seasonCount":null,"communityRating":7.712,"criticRating":97,"genres":["Comedy","Drama","Romance"],"overview":"New York comedian Alvy Singer falls in love with the ditsy Annie Hall.","poster":"assets/movie-annie-hall-1977-poster.jpg","backdrop":"assets/movie-annie-hall-1977-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:23.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":703,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":293349,"tmdb":7.71,"tmdbVotes":4391}},{"id":"movie-apocalypse-now-1979","title":"Apocalypse Now","year":1979,"type":"movie","runtimeSeconds":8835,"seasonCount":null,"communityRating":8.261,"criticRating":91,"genres":["Drama","War"],"overview":"At the height of the Vietnam war, Captain Benjamin Willard is sent on a dangerous mission that, officially, \"does not exist, nor will it ever exist.\" His goal is to locate - and eliminate - a mysterious Green Beret\u2026","poster":"assets/movie-apocalypse-now-1979-poster.jpg","backdrop":"assets/movie-apocalypse-now-1979-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":28,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":781424,"tmdb":8.3,"tmdbVotes":9526}},{"id":"movie-arrival-2016","title":"Arrival","year":2016,"type":"movie","runtimeSeconds":6967,"seasonCount":null,"communityRating":7.636,"criticRating":94,"genres":["Drama","Science Fiction","Mystery"],"overview":"Taking place after alien crafts land around the world, an expert linguist is recruited by the military to determine whether they come in peace or are a threat.","poster":"assets/movie-arrival-2016-poster.jpg","backdrop":"assets/movie-arrival-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-14T19:54:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":329865,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":883621,"tmdb":7.638,"tmdbVotes":20125}},{"id":"movie-avatar-aang-the-last-airbender-2026","title":"Avatar Aang: The Last Airbender","year":2026,"type":"movie","runtimeSeconds":5930,"seasonCount":null,"communityRating":9.147,"criticRating":90,"genres":["Animation","Adventure","Fantasy"],"overview":"Avatar Aang, the world's last Airbender, learns of an ancient power that could save his culture from extinction. With the help of his friends, he embarks on a global quest to find it before it falls into the wrong\u2026","poster":"assets/movie-avatar-aang-the-last-airbender-2026-poster.jpg","backdrop":"assets/movie-avatar-aang-the-last-airbender-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-25T09:53:09.0000000Z","lastPlayedAt":"2026-08-02T19:22:33.6155383Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":980431,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":30792,"tmdb":9,"tmdbVotes":1417}},{"id":"movie-bohemian-rhapsody-2018","title":"Bohemian Rhapsody","year":2018,"type":"movie","runtimeSeconds":8077,"seasonCount":null,"communityRating":7.95,"criticRating":60,"genres":["Music","Drama"],"overview":"Singer Freddie Mercury, guitarist Brian May, drummer Roger Taylor and bass guitarist John Deacon take the music world by storm when they form the rock 'n' roll band Queen in 1970. Hit songs become instant classics.\u2026","poster":"assets/movie-bohemian-rhapsody-2018-poster.jpg","backdrop":"assets/movie-bohemian-rhapsody-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":424694,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":655894,"tmdb":7.947,"tmdbVotes":18196}},{"id":"movie-the-boy-and-the-heron-2023","title":"The Boy and the Heron","year":2023,"type":"movie","runtimeSeconds":7437,"seasonCount":null,"communityRating":7.4,"criticRating":96,"genres":["Animation","Fantasy","Drama"],"overview":"While the Second World War rages, the teenage Mahito, haunted by his mother's tragic death, is relocated from Tokyo to the serene rural home of his new stepmother Natsuko, a woman who bears a striking resemblance to\u2026","poster":"assets/movie-the-boy-and-the-heron-2023-poster.jpg","backdrop":"assets/movie-the-boy-and-the-heron-2023-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":508883,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":152169,"tmdb":7.387,"tmdbVotes":2893}},{"id":"movie-braveheart-1995","title":"Braveheart","year":1995,"type":"movie","runtimeSeconds":10665,"seasonCount":null,"communityRating":7.945,"criticRating":74,"genres":["Drama","History","War"],"overview":"Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the\u2026","poster":"assets/movie-braveheart-1995-poster.jpg","backdrop":"assets/movie-braveheart-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:24:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":1174990,"tmdb":7.947,"tmdbVotes":11584}},{"id":"movie-the-breakfast-club-1985","title":"The Breakfast Club","year":1985,"type":"movie","runtimeSeconds":5833,"seasonCount":null,"communityRating":7.7,"criticRating":87,"genres":["Comedy","Drama"],"overview":"Five high school students from different walks of life endure a Saturday detention under a power-hungry principal. The disparate group includes rebel John, princess Claire, outcast Allison, brainy Brian and Andrew,\u2026","poster":"assets/movie-the-breakfast-club-1985-poster.jpg","backdrop":"assets/movie-the-breakfast-club-1985-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2108,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":478743,"tmdb":7.713,"tmdbVotes":8815}},{"id":"movie-castle-in-the-sky-1986","title":"Castle in the Sky","year":1986,"type":"movie","runtimeSeconds":7474,"seasonCount":null,"communityRating":8,"criticRating":97,"genres":["Adventure","Fantasy","Animation"],"overview":"A young boy and a girl with a magic crystal must race against pirates and foreign agents in a search for a legendary floating castle.","poster":"assets/movie-castle-in-the-sky-1986-poster.jpg","backdrop":"assets/movie-castle-in-the-sky-1986-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":10515,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":201545,"tmdb":7.972,"tmdbVotes":4868}},{"id":"movie-citizen-kane-1941","title":"Citizen Kane","year":1941,"type":"movie","runtimeSeconds":7173,"seasonCount":null,"communityRating":7.966,"criticRating":99,"genres":["Mystery","Drama"],"overview":"Newspaper magnate Charles Foster Kane is taken from his mother as a boy and made the ward of a rich industrialist. As a result, every well-meaning, tyrannical or self-destructive move he makes for the rest of his\u2026","poster":"assets/movie-citizen-kane-1941-poster.jpg","backdrop":"assets/movie-citizen-kane-1941-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:16.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":15,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":498321,"tmdb":7.962,"tmdbVotes":6324}},{"id":"movie-conclave-2024","title":"Conclave","year":2024,"type":"movie","runtimeSeconds":7230,"seasonCount":null,"communityRating":7.19,"criticRating":93,"genres":["Drama","Thriller"],"overview":"After the unexpected death of the Pope, Cardinal Lawrence is tasked with managing the covert and ancient ritual of electing a new one. Sequestered in the Vatican with the Catholic Church\u2019s most powerful leaders until\u2026","poster":"assets/movie-conclave-2024-poster.jpg","backdrop":"assets/movie-conclave-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:41.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":974576,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":290498,"tmdb":7.19,"tmdbVotes":3744}},{"id":"movie-the-dry-2021","title":"The Dry","year":2021,"type":"movie","runtimeSeconds":7037,"seasonCount":null,"communityRating":6.779,"criticRating":null,"genres":["Crime","Thriller","Mystery"],"overview":"Aaron Falk returns to his drought-stricken hometown to attend a tragic funeral. But his return opens a decades-old wound - the unsolved death of a teenage girl.","poster":"assets/movie-the-dry-2021-poster.jpg","backdrop":"assets/movie-the-dry-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":567797,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":41935,"tmdb":6.8,"tmdbVotes":892}},{"id":"movie-easy-a-2010","title":"Easy A","year":2010,"type":"movie","runtimeSeconds":5545,"seasonCount":null,"communityRating":6.8,"criticRating":85,"genres":["Comedy"],"overview":"Olive, an average high school student, sees her below-the-radar existence turn around overnight once she decides to use the school's gossip grapevine to advance her social standing. Now her classmates are turning\u2026","poster":"assets/movie-easy-a-2010-poster.jpg","backdrop":"assets/movie-easy-a-2010-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-17T19:59:04.0000000Z","lastPlayedAt":"2026-10-17T20:02:51.1083337Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":37735,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":448780,"tmdb":6.8,"tmdbVotes":7619}},{"id":"movie-el-camino-a-breaking-bad-movie-2019","title":"El Camino: A Breaking Bad Movie","year":2019,"type":"movie","runtimeSeconds":7362,"seasonCount":null,"communityRating":7.055,"criticRating":92,"genres":["Crime","Drama","Thriller"],"overview":"In the wake of his dramatic escape from captivity, Jesse Pinkman must come to terms with his past in order to forge some kind of future.","poster":"assets/movie-el-camino-a-breaking-bad-movie-2019-poster.jpg","backdrop":"assets/movie-el-camino-a-breaking-bad-movie-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-21T23:31:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":559969,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":354488,"tmdb":7.056,"tmdbVotes":5950}},{"id":"movie-emancipation-2022","title":"Emancipation","year":2022,"type":"movie","runtimeSeconds":7949,"seasonCount":null,"communityRating":7.7,"criticRating":45,"genres":["Drama","War","History"],"overview":"Inspired by the gripping true story of a man who would do anything for his family\u2014and for freedom. When Peter, an enslaved man, risks his life to escape and return to his family, he embarks on a perilous journey of\u2026","poster":"assets/movie-emancipation-2022-poster.jpg","backdrop":"assets/movie-emancipation-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:37:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":715931,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":45,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.3,"imdbVotes":32079,"tmdb":7.744,"tmdbVotes":1319}},{"id":"movie-fighting-with-my-family-2019","title":"Fighting with My Family","year":2019,"type":"movie","runtimeSeconds":6509,"seasonCount":null,"communityRating":6.84,"criticRating":93,"genres":["Comedy"],"overview":"Born into a tight-knit wrestling family, Paige and her brother Zak are ecstatic when they get the once-in-a-lifetime opportunity to try out for the WWE. But when only Paige earns a spot in the competitive training\u2026","poster":"assets/movie-fighting-with-my-family-2019-poster.jpg","backdrop":"assets/movie-fighting-with-my-family-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T22:10:59.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":445629,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":94576,"tmdb":6.837,"tmdbVotes":1952}},{"id":"movie-force-of-nature-the-dry-2-2024","title":"Force of Nature: The Dry 2","year":2024,"type":"movie","runtimeSeconds":6753,"seasonCount":null,"communityRating":6.3,"criticRating":66,"genres":["Mystery","Thriller"],"overview":"When five women take part in a corporate hiking retreat and only four come out on the other side, Federal Agents Aaron Falk and Carmen Cooper head deep into the Victorian mountain ranges to investigate in the hopes\u2026","poster":"assets/movie-force-of-nature-the-dry-2-2024-poster.jpg","backdrop":"assets/movie-force-of-nature-the-dry-2-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":832262,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":43,"rtAudienceLabel":"Spilled","imdb":5.9,"imdbVotes":10343,"tmdb":6.269,"tmdbVotes":305}},{"id":"movie-free-state-of-jones-2016","title":"Free State of Jones","year":2016,"type":"movie","runtimeSeconds":8376,"seasonCount":null,"communityRating":6.79,"criticRating":48,"genres":["Action","Drama","History"],"overview":"In 1863, Mississippi farmer Newt Knight serves as a medic for the Confederate Army. Opposed to slavery, Knight would rather help the wounded than fight the Union. After his nephew dies in battle, Newt returns home to\u2026","poster":"assets/movie-free-state-of-jones-2016-poster.jpg","backdrop":"assets/movie-free-state-of-jones-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-11T17:56:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":316152,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":68931,"tmdb":6.788,"tmdbVotes":1730}},{"id":"movie-from-up-on-poppy-hill-2011","title":"From Up on Poppy Hill","year":2011,"type":"movie","runtimeSeconds":5482,"seasonCount":null,"communityRating":7.5,"criticRating":87,"genres":["Animation","Drama"],"overview":"Yokohama, 1963. Japan is picking itself up from the devastation of World War II and preparing to host the 1964 Olympics\u2014and the mood is one of both optimism and conflict as the young generation struggles to throw off\u2026","poster":"assets/movie-from-up-on-poppy-hill-2011-poster.jpg","backdrop":"assets/movie-from-up-on-poppy-hill-2011-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T14:41:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":83389,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":60769,"tmdb":7.509,"tmdbVotes":2026}},{"id":"movie-ghost-in-the-shell-1995","title":"Ghost in the Shell","year":1995,"type":"movie","runtimeSeconds":4959,"seasonCount":null,"communityRating":7.929,"criticRating":95,"genres":["Action","Animation","Science Fiction"],"overview":"In the year 2029, the barriers of our world have been broken down by the net and by cybernetics, but this brings new vulnerability to humans in the form of brain-hacking. When a highly-wanted hacker known as 'The\u2026","poster":"assets/movie-ghost-in-the-shell-1995-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-1995-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:57.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9323,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":178061,"tmdb":7.928,"tmdbVotes":4130}},{"id":"movie-ghost-in-the-shell-2-innocence-2004","title":"Ghost in the Shell 2: Innocence","year":2004,"type":"movie","runtimeSeconds":5950,"seasonCount":null,"communityRating":7.352,"criticRating":65,"genres":["Animation","Drama","Science Fiction"],"overview":"Cyborg detective Batou is assigned to investigate a series of murders committed by gynoids\u2014doll-like cyborgs, which all malfunctioned, killed, then self-destructed afterwards. The brains of the gynoids initialize in\u2026","poster":"assets/movie-ghost-in-the-shell-2-innocence-2004-poster.jpg","backdrop":"assets/movie-ghost-in-the-shell-2-innocence-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12140,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":65,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":43280,"tmdb":7.356,"tmdbVotes":1049}},{"id":"movie-the-godfather-1972","title":"The Godfather","year":1972,"type":"movie","runtimeSeconds":10603,"seasonCount":null,"communityRating":8.687,"criticRating":97,"genres":["Drama","Crime"],"overview":"Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son,\u2026","poster":"assets/movie-the-godfather-1972-poster.jpg","backdrop":"assets/movie-the-godfather-1972-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:12.0000000Z","lastPlayedAt":"2026-09-18T19:24:44.9855867Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":238,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":9.2,"imdbVotes":2262024,"tmdb":8.688,"tmdbVotes":23907}},{"id":"movie-the-godfather-part-ii-1974","title":"The Godfather Part II","year":1974,"type":"movie","runtimeSeconds":12031,"seasonCount":null,"communityRating":8.573,"criticRating":96,"genres":["Drama","Crime"],"overview":"In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and\u2026","poster":"assets/movie-the-godfather-part-ii-1974-poster.jpg","backdrop":"assets/movie-the-godfather-part-ii-1974-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:18.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":240,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":9,"imdbVotes":1521308,"tmdb":8.571,"tmdbVotes":14559}},{"id":"movie-the-godfather-part-iii-1990","title":"The Godfather Part III","year":1990,"type":"movie","runtimeSeconds":9476,"seasonCount":null,"communityRating":7.416,"criticRating":67,"genres":["Crime","Drama","Thriller"],"overview":"In the midst of trying to legitimize his business dealings in 1979 New York and Italy, aging mafia don, Michael Corleone seeks forgiveness for his sins while taking a young protege under his wing.","poster":"assets/movie-the-godfather-part-iii-1990-poster.jpg","backdrop":"assets/movie-the-godfather-part-iii-1990-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:29.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":242,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":68,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":462448,"tmdb":7.413,"tmdbVotes":7159}},{"id":"movie-gosford-park-2001","title":"Gosford Park","year":2001,"type":"movie","runtimeSeconds":8247,"seasonCount":null,"communityRating":6.815,"criticRating":87,"genres":["Drama","Mystery","Thriller"],"overview":"In 1930s England, a group of pretentious rich and famous gather together for a weekend of relaxation at a hunting resort. But when a murder occurs, each one of these interesting characters becomes a suspect.","poster":"assets/movie-gosford-park-2001-poster.jpg","backdrop":"assets/movie-gosford-park-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":5279,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":103883,"tmdb":6.822,"tmdbVotes":1317}},{"id":"movie-grave-of-the-fireflies-1988","title":"Grave of the Fireflies","year":1988,"type":"movie","runtimeSeconds":5313,"seasonCount":null,"communityRating":8.45,"criticRating":100,"genres":["Animation","Drama","War"],"overview":"In the final months of World War II, 14-year-old Seita and his sister Setsuko are orphaned when their mother is killed during an air raid in Kobe, Japan. After a falling out with their aunt, they move into an\u2026","poster":"assets/movie-grave-of-the-fireflies-1988-poster.jpg","backdrop":"assets/movie-grave-of-the-fireflies-1988-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:14.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12477,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":8.5,"imdbVotes":399802,"tmdb":8.45,"tmdbVotes":6938}},{"id":"movie-the-great-gatsby-2013","title":"The Great Gatsby","year":2013,"type":"movie","runtimeSeconds":8525,"seasonCount":null,"communityRating":7.346,"criticRating":49,"genres":["Drama","Romance"],"overview":"Nick Carraway arrives in New York in 1922, an era of loose morals, glittering jazz and bootleg kings. Chasing his own American Dream, Nick encounters the mysterious millionaire Gatsby and his bewitching cousin Daisy.\u2026","poster":"assets/movie-the-great-gatsby-2013-poster.jpg","backdrop":"assets/movie-the-great-gatsby-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:50.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64682,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":48,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":651882,"tmdb":7.346,"tmdbVotes":13290}},{"id":"movie-the-hours-2002","title":"The Hours","year":2002,"type":"movie","runtimeSeconds":6887,"seasonCount":null,"communityRating":7.25,"criticRating":79,"genres":["Drama"],"overview":"The story of three women searching for more potent, meaningful lives. Each is alive at a different time and place, all are linked by their yearnings and their fears. Their stories intertwine, and finally come\u2026","poster":"assets/movie-the-hours-2002-poster.jpg","backdrop":"assets/movie-the-hours-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":590,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":148367,"tmdb":7.249,"tmdbVotes":1931}},{"id":"movie-howl-s-moving-castle-2004","title":"Howl's Moving Castle","year":2004,"type":"movie","runtimeSeconds":7149,"seasonCount":null,"communityRating":8.4,"criticRating":88,"genres":["Fantasy","Animation","Adventure"],"overview":"Sophie, a young milliner, is turned into an elderly woman by a witch who enters her shop and curses her. She encounters a wizard named Howl and gets caught up in his resistance to fighting for the king.","poster":"assets/movie-howl-s-moving-castle-2004-poster.jpg","backdrop":"assets/movie-howl-s-moving-castle-2004-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:47.0000000Z","lastPlayedAt":"2026-05-21T18:46:08.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4935,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":524520,"tmdb":8.391,"tmdbVotes":11642}},{"id":"movie-in-the-heights-2021","title":"In the Heights","year":2021,"type":"movie","runtimeSeconds":8579,"seasonCount":null,"communityRating":7.111,"criticRating":null,"genres":["Drama","Romance"],"overview":"The story of Usnavi, a bodega owner who has mixed feelings about closing his store and retiring to the Dominican Republic or staying in Washington Heights.","poster":"assets/movie-in-the-heights-2021-poster.jpg","backdrop":"assets/movie-in-the-heights-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:11.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":467909,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":56513,"tmdb":7.104,"tmdbVotes":826}},{"id":"movie-jennifer-s-body-2009","title":"Jennifer's Body","year":2009,"type":"movie","runtimeSeconds":6450,"seasonCount":null,"communityRating":6.012,"criticRating":47,"genres":["Horror","Comedy"],"overview":"Jennifer, a gorgeous, seductive cheerleader takes evil to a whole new level after she's possessed by a sinister demon. Now it's up to her best friend to stop Jennifer's reign of terror before it's too late.","poster":"assets/movie-jennifer-s-body-2009-poster.jpg","backdrop":"assets/movie-jennifer-s-body-2009-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-30T03:37:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":19994,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":47,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":179710,"tmdb":6.013,"tmdbVotes":4481}},{"id":"movie-kiki-s-delivery-service-1989","title":"Kiki's Delivery Service","year":1989,"type":"movie","runtimeSeconds":6183,"seasonCount":null,"communityRating":7.839,"criticRating":null,"genres":["Animation","Family","Fantasy"],"overview":"A young witch, on her mandatory year of independent life, finds fitting into a new community difficult while she supports herself by running an air courier service.","poster":"assets/movie-kiki-s-delivery-service-1989-poster.jpg","backdrop":"assets/movie-kiki-s-delivery-service-1989-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":16859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":192477,"tmdb":7.839,"tmdbVotes":4811}},{"id":"movie-the-last-of-the-mohicans-1992","title":"The Last of the Mohicans","year":1992,"type":"movie","runtimeSeconds":6874,"seasonCount":null,"communityRating":7.388,"criticRating":88,"genres":["History","War","Drama"],"overview":"In war-torn colonial America, in the midst of a bloody battle between British, the French and Native American allies, the aristocratic daughter of a British Colonel and her party are captured by a group of Huron\u2026","poster":"assets/movie-the-last-of-the-mohicans-1992-poster.jpg","backdrop":"assets/movie-the-last-of-the-mohicans-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:59:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9361,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":210791,"tmdb":7.39,"tmdbVotes":3593}},{"id":"movie-the-last-samurai-2003","title":"The Last Samurai","year":2003,"type":"movie","runtimeSeconds":9251,"seasonCount":null,"communityRating":7.61,"criticRating":65,"genres":["Drama","Action","War"],"overview":"Nathan Algren is an American hired to instruct the Japanese army in the ways of modern warfare, which finds him learning to respect the samurai and the honorable principles that rule them. Pressed to destroy the\u2026","poster":"assets/movie-the-last-samurai-2003-poster.jpg","backdrop":"assets/movie-the-last-samurai-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:25:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":616,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":512982,"tmdb":7.614,"tmdbVotes":7887}},{"id":"movie-lemony-snicket-s-a-series-of-unfortunate-events-2004","title":"Lemony Snicket's A Series of Unfortunate Events","year":2004,"type":"movie","runtimeSeconds":6479,"seasonCount":null,"communityRating":6.8,"criticRating":72,"genres":["Adventure","Comedy","Family"],"overview":"Three wealthy children's parents are killed in a fire. When they are sent to a distant relative, they find out that he is plotting to kill them and seize their fortune.","poster":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-poster.jpg","backdrop":"assets/movie-lemony-snicket-s-a-series-of-unfortunate-events-2004-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:09.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11774,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":233636,"tmdb":6.82,"tmdbVotes":5447}},{"id":"movie-the-lighthouse-2019","title":"The Lighthouse","year":2019,"type":"movie","runtimeSeconds":6549,"seasonCount":null,"communityRating":7.463,"criticRating":90,"genres":["Drama","Fantasy","Thriller"],"overview":"Two lighthouse keepers try to maintain their sanity while living on a remote and mysterious New England island in the 1890s.","poster":"assets/movie-the-lighthouse-2019-poster.jpg","backdrop":"assets/movie-the-lighthouse-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":503919,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":312344,"tmdb":7.46,"tmdbVotes":5895}},{"id":"movie-locke-2014","title":"Locke","year":2014,"type":"movie","runtimeSeconds":5090,"seasonCount":null,"communityRating":6.888,"criticRating":91,"genres":["Drama","Thriller"],"overview":"Ivan Locke has worked hard to craft a good life for himself. Tonight, that life will collapse around him. On the eve of the biggest challenge of his career, Ivan receives a phone call that sets in motion a series of\u2026","poster":"assets/movie-locke-2014-poster.jpg","backdrop":"assets/movie-locke-2014-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:38:48.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":171265,"tmdb":6.889,"tmdbVotes":3088}},{"id":"movie-the-matrix-1999","title":"The Matrix","year":1999,"type":"movie","runtimeSeconds":8179,"seasonCount":null,"communityRating":8.259,"criticRating":83,"genres":["Action","Science Fiction"],"overview":"Set in the 22nd century, The Matrix tells the story of a computer hacker who joins a group of underground insurgents fighting the vast and powerful computers who now rule the earth.","poster":"assets/movie-the-matrix-1999-poster.jpg","backdrop":"assets/movie-the-matrix-1999-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:18:12.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":603,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":8.7,"imdbVotes":2289808,"tmdb":8.262,"tmdbVotes":29153}},{"id":"movie-the-matrix-reloaded-2003","title":"The Matrix Reloaded","year":2003,"type":"movie","runtimeSeconds":8298,"seasonCount":null,"communityRating":7.085,"criticRating":74,"genres":["Adventure","Action","Thriller"],"overview":"The Resistance builds in numbers as humans are freed from the Matrix and brought to the city of Zion. Neo discovers his superpowers, including the ability to see the code inside the Matrix. With machine sentinels\u2026","poster":"assets/movie-the-matrix-reloaded-2003-poster.jpg","backdrop":"assets/movie-the-matrix-reloaded-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":604,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":678013,"tmdb":7.087,"tmdbVotes":12504}},{"id":"movie-the-matrix-resurrections-2021","title":"The Matrix Resurrections","year":2021,"type":"movie","runtimeSeconds":8875,"seasonCount":null,"communityRating":6.303,"criticRating":63,"genres":["Science Fiction","Action","Adventure"],"overview":"Plagued by strange memories, Neo's life takes an unexpected turn when he finds himself back inside the Matrix.","poster":"assets/movie-the-matrix-resurrections-2021-poster.jpg","backdrop":"assets/movie-the-matrix-resurrections-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":624860,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":63,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":307780,"tmdb":6.294,"tmdbVotes":6842}},{"id":"movie-the-matrix-revolutions-2003","title":"The Matrix Revolutions","year":2003,"type":"movie","runtimeSeconds":7756,"seasonCount":null,"communityRating":6.758,"criticRating":33,"genres":["Adventure","Action","Thriller"],"overview":"The human city of Zion defends itself against the massive invasion of the machines as Neo fights to end the war at another front while also opposing the rogue Agent Smith.","poster":"assets/movie-the-matrix-revolutions-2003-poster.jpg","backdrop":"assets/movie-the-matrix-revolutions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:25:47.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":605,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":34,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":581553,"tmdb":6.762,"tmdbVotes":11292}},{"id":"movie-mercy-2026","title":"Mercy","year":2026,"type":"movie","runtimeSeconds":5968,"seasonCount":null,"communityRating":7.077,"criticRating":null,"genres":["Science Fiction","Thriller","Crime"],"overview":"In the near future, a detective stands on trial accused of murdering his wife. He has ninety minutes to prove his innocence to the advanced AI Judge he once championed, before it determines his fate.","poster":"assets/movie-mercy-2026-poster.jpg","backdrop":"assets/movie-mercy-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1236153,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":24,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":83878,"tmdb":7.054,"tmdbVotes":1812}},{"id":"movie-normal-2026","title":"Normal","year":2026,"type":"movie","runtimeSeconds":5514,"seasonCount":null,"communityRating":7.267,"criticRating":77,"genres":["Action","Crime","Thriller"],"overview":"An unassuming substitute sheriff with a troubled past responds to a bank robbery in a small, sleepy town and unknowingly uncovers something far more explosive.","poster":"assets/movie-normal-2026-poster.jpg","backdrop":"assets/movie-normal-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-21T08:33:18.0000000Z","lastPlayedAt":"2026-09-24T23:07:52.1161581Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1242332,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":78,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.3,"imdbVotes":37501,"tmdb":7.241,"tmdbVotes":786}},{"id":"movie-outlaw-king-2018","title":"Outlaw King","year":2018,"type":"movie","runtimeSeconds":7297,"seasonCount":null,"communityRating":6.829,"criticRating":63,"genres":["Action","History","War"],"overview":"Forced into exile by the English after being crowned King of Scotland, legendary warrior Robert the Bruce fights to reclaim the throne.","poster":"assets/movie-outlaw-king-2018-poster.jpg","backdrop":"assets/movie-outlaw-king-2018-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:32.0000000Z","lastPlayedAt":"2026-07-26T22:35:35.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":454227,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":89876,"tmdb":6.844,"tmdbVotes":1780}},{"id":"movie-panic-room-2002","title":"Panic Room","year":2002,"type":"movie","runtimeSeconds":6717,"seasonCount":null,"communityRating":6.794,"criticRating":76,"genres":["Crime","Drama","Thriller"],"overview":"Trapped in their New York brownstone's panic room, a hidden chamber built as a sanctuary in the event of break-ins, newly divorced Meg Altman and her young daughter Sarah play a deadly game of cat-and-mouse with\u2026","poster":"assets/movie-panic-room-2002-poster.jpg","backdrop":"assets/movie-panic-room-2002-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-19T20:50:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4547,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":76,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":317468,"tmdb":6.796,"tmdbVotes":5653}},{"id":"movie-the-patriot-2000","title":"The Patriot","year":2000,"type":"movie","runtimeSeconds":9887,"seasonCount":null,"communityRating":7.214,"criticRating":61,"genres":["Drama","History","War"],"overview":"After proving himself on the field of battle in the French and Indian War, Benjamin Martin wants nothing more to do with such things, preferring the simple life of a farmer. But when his son Gabriel enlists in the\u2026","poster":"assets/movie-the-patriot-2000-poster.jpg","backdrop":"assets/movie-the-patriot-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T20:50:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2024,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":81,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":317639,"tmdb":7.215,"tmdbVotes":4535}},{"id":"movie-peaky-blinders-the-immortal-man-2026","title":"Peaky Blinders: The Immortal Man","year":2026,"type":"movie","runtimeSeconds":6844,"seasonCount":null,"communityRating":7.187,"criticRating":90,"genres":["Crime","Drama"],"overview":"After his estranged son gets embroiled in a Nazi plot, self-exiled gangster Tommy Shelby must return to Birmingham to save his family \u2014 and his nation.","poster":"assets/movie-peaky-blinders-the-immortal-man-2026-poster.jpg","backdrop":"assets/movie-peaky-blinders-the-immortal-man-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:01.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":875828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":92130,"tmdb":7.189,"tmdbVotes":1304}},{"id":"movie-ponyo-2008","title":"Ponyo","year":2008,"type":"movie","runtimeSeconds":6530,"seasonCount":null,"communityRating":7.763,"criticRating":91,"genres":["Animation","Fantasy","Family"],"overview":"When Sosuke, a young boy who lives on a clifftop overlooking the sea, rescues a stranded goldfish named Ponyo, he discovers more than he bargained for. Ponyo is a curious, energetic young creature who yearns to be\u2026","poster":"assets/movie-ponyo-2008-poster.jpg","backdrop":"assets/movie-ponyo-2008-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:53.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":12429,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":190294,"tmdb":7.759,"tmdbVotes":5050}},{"id":"movie-porco-rosso-1992","title":"Porco Rosso","year":1992,"type":"movie","runtimeSeconds":5600,"seasonCount":null,"communityRating":7.794,"criticRating":96,"genres":["Animation","Adventure","Fantasy"],"overview":"In Italy in the 1930s, sky pirates in biplanes terrorize wealthy cruise ships as they sail the Adriatic Sea. The only pilot brave enough to stop the scourge is the mysterious Porco Rosso, a former World War I flying\u2026","poster":"assets/movie-porco-rosso-1992-poster.jpg","backdrop":"assets/movie-porco-rosso-1992-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":11621,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":119396,"tmdb":7.789,"tmdbVotes":3879}},{"id":"movie-princess-mononoke-1997","title":"Princess Mononoke","year":1997,"type":"movie","runtimeSeconds":8003,"seasonCount":null,"communityRating":8.321,"criticRating":93,"genres":["Adventure","Fantasy","Animation"],"overview":"Ashitaka, a prince of the disappearing Emishi people, is cursed by a demonized boar god and must journey to the west to find a cure. Along the way, he encounters San, a young human woman fighting to protect the\u2026","poster":"assets/movie-princess-mononoke-1997-poster.jpg","backdrop":"assets/movie-princess-mononoke-1997-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:28.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":128,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":493735,"tmdb":8.321,"tmdbVotes":9438}},{"id":"movie-prisoners-2013","title":"Prisoners","year":2013,"type":"movie","runtimeSeconds":9192,"seasonCount":null,"communityRating":8.106,"criticRating":81,"genres":["Drama","Thriller","Crime"],"overview":"After his six-year-old daughter is abducted, a desperate father takes justice into his own hands when he loses faith in the detective leading the investigation, setting both men on a collision course as the search\u2026","poster":"assets/movie-prisoners-2013-poster.jpg","backdrop":"assets/movie-prisoners-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:33.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":146233,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":963062,"tmdb":8.105,"tmdbVotes":13722}},{"id":"movie-racing-stripes-2005","title":"Racing Stripes","year":2005,"type":"movie","runtimeSeconds":6117,"seasonCount":null,"communityRating":5.945,"criticRating":35,"genres":["Comedy","Family"],"overview":"Shattered illusions are hard to repair -- especially for a good-hearted zebra named Stripes who's spent his life on a Kentucky farm amidst the sorely mistaken notion that he's a debonair thoroughbred. Once he faces\u2026","poster":"assets/movie-racing-stripes-2005-poster.jpg","backdrop":"assets/movie-racing-stripes-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":6439,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":42,"rtAudienceLabel":"Spilled","imdb":5.3,"imdbVotes":19569,"tmdb":5.962,"tmdbVotes":705}},{"id":"movie-requiem-for-a-dream-2000","title":"Requiem for a Dream","year":2000,"type":"movie","runtimeSeconds":6086,"seasonCount":null,"communityRating":8.005,"criticRating":80,"genres":["Crime","Drama"],"overview":"The drug-induced utopias of four Coney Island residents are shattered when their addictions run deep.","poster":"assets/movie-requiem-for-a-dream-2000-poster.jpg","backdrop":"assets/movie-requiem-for-a-dream-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":641,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.3,"imdbVotes":989090,"tmdb":8,"tmdbVotes":11454}},{"id":"movie-rogue-one-a-star-wars-story-2016","title":"Rogue One: A Star Wars Story","year":2016,"type":"movie","runtimeSeconds":8038,"seasonCount":null,"communityRating":7.51,"criticRating":84,"genres":["Action","Adventure","Science Fiction"],"overview":"A rogue band of resistance fighters unite for a mission to steal the Death Star plans and bring a new hope to the galaxy.","poster":"assets/movie-rogue-one-a-star-wars-story-2016-poster.jpg","backdrop":"assets/movie-rogue-one-a-star-wars-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":330459,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":768365,"tmdb":7.512,"tmdbVotes":16937}},{"id":"movie-secondhand-lions-2003","title":"Secondhand Lions","year":2003,"type":"movie","runtimeSeconds":6547,"seasonCount":null,"communityRating":7.237,"criticRating":60,"genres":["Family","Comedy","Drama"],"overview":"The comedic adventures of an introverted boy left on the doorstep of a pair of reluctant, eccentric great-uncles, whose exotic remembrances stir the boy's spirit and re-ignite the men's lives.","poster":"assets/movie-secondhand-lions-2003-poster.jpg","backdrop":"assets/movie-secondhand-lions-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-12T07:16:13.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":13156,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":59,"rtCriticsLabel":"Rotten","rtAudience":84,"rtAudienceLabel":"Upright","imdb":7.5,"imdbVotes":66911,"tmdb":7.242,"tmdbVotes":872}},{"id":"movie-the-secret-life-of-pets-2-2019","title":"The Secret Life of Pets 2","year":2019,"type":"movie","runtimeSeconds":5164,"seasonCount":null,"communityRating":6.95,"criticRating":60,"genres":["Animation","Comedy","Family"],"overview":"Max the terrier must cope with some major life changes when his owner gets married and has a baby. When the family takes a trip to the countryside, nervous Max has numerous run-ins with canine-intolerant cows,\u2026","poster":"assets/movie-the-secret-life-of-pets-2-2019-poster.jpg","backdrop":"assets/movie-the-secret-life-of-pets-2-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-05T07:00:53.0000000Z","lastPlayedAt":"2026-09-17T18:07:00.7024231Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":412117,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":80464,"tmdb":6.948,"tmdbVotes":3450}},{"id":"movie-self-reliance-2024","title":"Self Reliance","year":2024,"type":"movie","runtimeSeconds":5461,"seasonCount":null,"communityRating":5.888,"criticRating":73,"genres":["Comedy","Thriller"],"overview":"When a man is offered a million dollars to play a game in which hunters try to kill him, he thinks he has found the perfect loophole: they can only attack when he\u2019s alone. His only problem is that none of his friends\u2026","poster":"assets/movie-self-reliance-2024-poster.jpg","backdrop":"assets/movie-self-reliance-2024-backdrop.jpg","played":false,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:00.0000000Z","lastPlayedAt":"2026-10-02T19:59:16.9286868Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1072876,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":73,"rtCriticsLabel":"Certified Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":23771,"tmdb":5.866,"tmdbVotes":346}},{"id":"movie-serpico-1973","title":"Serpico","year":1973,"type":"movie","runtimeSeconds":7805,"seasonCount":null,"communityRating":7.5,"criticRating":93,"genres":["Crime","Drama","History"],"overview":"New York cop Frank Serpico blows the whistle on the rampant corruption in the force only to have his comrades turn against him.","poster":"assets/movie-serpico-1973-poster.jpg","backdrop":"assets/movie-serpico-1973-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:45:02.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":9040,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":88,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":149750,"tmdb":7.525,"tmdbVotes":2323}},{"id":"movie-shaft-2000","title":"Shaft","year":2000,"type":"movie","runtimeSeconds":5960,"seasonCount":null,"communityRating":6.026,"criticRating":67,"genres":["Action","Adventure","Crime"],"overview":"New York police detective John Shaft arrests Walter Wade Jr. for a racially motivated slaying. But the only eyewitness disappears, and Wade jumps bail for Switzerland. Two years later Wade returns to face trial,\u2026","poster":"assets/movie-shaft-2000-poster.jpg","backdrop":"assets/movie-shaft-2000-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T21:30:40.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":479,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":68,"rtCriticsLabel":"Fresh","rtAudience":40,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":86257,"tmdb":6.024,"tmdbVotes":1531}},{"id":"movie-a-silent-voice-the-movie-2016","title":"A Silent Voice: The Movie","year":2016,"type":"movie","runtimeSeconds":7804,"seasonCount":null,"communityRating":8.398,"criticRating":null,"genres":["Animation","Drama","Romance"],"overview":"Shouya Ishida starts bullying the new girl in class, Shouko Nishimiya, because she is deaf. But as the teasing continues, the rest of the class starts to turn on Shouya for his lack of compassion. When they leave\u2026","poster":"assets/movie-a-silent-voice-the-movie-2016-poster.jpg","backdrop":"assets/movie-a-silent-voice-the-movie-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T17:39:46.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":378064,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":94,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":151926,"tmdb":8.396,"tmdbVotes":4764}},{"id":"movie-sinbad-legend-of-the-seven-seas-2003","title":"Sinbad: Legend of the Seven Seas","year":2003,"type":"movie","runtimeSeconds":5143,"seasonCount":null,"communityRating":7.067,"criticRating":44,"genres":["Action","Adventure","Animation"],"overview":"The sailor of legend is framed by the goddess Eris for the theft of the Book of Peace, and must travel to her realm at the end of the world to retrieve it and save the life of his childhood friend Prince Proteus.","poster":"assets/movie-sinbad-legend-of-the-seven-seas-2003-poster.jpg","backdrop":"assets/movie-sinbad-legend-of-the-seven-seas-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:26:25.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":14411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":57,"rtAudienceLabel":"Spilled","imdb":6.8,"imdbVotes":65298,"tmdb":7.071,"tmdbVotes":2323}},{"id":"movie-sleeping-with-other-people-2015","title":"Sleeping with Other People","year":2015,"type":"movie","runtimeSeconds":6076,"seasonCount":null,"communityRating":6.274,"criticRating":64,"genres":["Comedy","Romance"],"overview":"Can two serial cheaters get a second chance at love? After a one-night stand in college, New Yorkers Lainey and Jake meet by chance twelve years later and discover they each have the same problem: because of their\u2026","poster":"assets/movie-sleeping-with-other-people-2015-poster.jpg","backdrop":"assets/movie-sleeping-with-other-people-2015-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:42.0000000Z","lastPlayedAt":"2026-07-17T17:50:44.0000000Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":288036,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":56,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":52275,"tmdb":6.275,"tmdbVotes":1020}},{"id":"movie-snowpiercer-2013","title":"Snowpiercer","year":2013,"type":"movie","runtimeSeconds":7573,"seasonCount":null,"communityRating":6.909,"criticRating":94,"genres":["Action","Science Fiction","Drama"],"overview":"In a future where a failed global-warming experiment kills off most life on the planet, a class system evolves aboard the Snowpiercer; a train that travels around the globe via a perpetual-motion engine.","poster":"assets/movie-snowpiercer-2013-poster.jpg","backdrop":"assets/movie-snowpiercer-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:27:07.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110415,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Certified Fresh","rtAudience":72,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":426976,"tmdb":6.9,"tmdbVotes":10807}},{"id":"movie-spider-man-brand-new-day-2026","title":"Spider-Man: Brand New Day","year":2026,"type":"movie","runtimeSeconds":8701,"seasonCount":null,"communityRating":8.374,"criticRating":90,"genres":["Science Fiction","Action","Adventure"],"overview":"Fighting crime full-time as Spider-Man in a world that doesn't remember him\u2014and the pressure of seeing his old friends move on without him\u2014sparks a change in Peter Parker he may not have the power to control. But\u2026","poster":"assets/movie-spider-man-brand-new-day-2026-poster.jpg","backdrop":"assets/movie-spider-man-brand-new-day-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:14:27.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":969681,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":366886,"tmdb":8.5,"tmdbVotes":6537}},{"id":"movie-spider-man-far-from-home-2019","title":"Spider-Man: Far From Home","year":2019,"type":"movie","runtimeSeconds":7762,"seasonCount":null,"communityRating":7.389,"criticRating":91,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and\u2026","poster":"assets/movie-spider-man-far-from-home-2019-poster.jpg","backdrop":"assets/movie-spider-man-far-from-home-2019-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:25:47.0000000Z","lastPlayedAt":"2026-10-10T17:50:35.6193119Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":95,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":682887,"tmdb":7.387,"tmdbVotes":17847}},{"id":"movie-spider-man-homecoming-2017","title":"Spider-Man: Homecoming","year":2017,"type":"movie","runtimeSeconds":8008,"seasonCount":null,"communityRating":7.333,"criticRating":92,"genres":["Action","Adventure","Science Fiction"],"overview":"Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as\u2026","poster":"assets/movie-spider-man-homecoming-2017-poster.jpg","backdrop":"assets/movie-spider-man-homecoming-2017-backdrop.jpg","played":true,"playCount":1,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:33:13.0000000Z","lastPlayedAt":"2026-10-10T20:31:23.4444213Z","availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":315635,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Certified Fresh","rtAudience":87,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":840974,"tmdb":7.332,"tmdbVotes":24183}},{"id":"movie-spider-man-no-way-home-2021","title":"Spider-Man: No Way Home","year":2021,"type":"movie","runtimeSeconds":8890,"seasonCount":null,"communityRating":7.947,"criticRating":93,"genres":["Action","Adventure","Science Fiction"],"overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to\u2026","poster":"assets/movie-spider-man-no-way-home-2021-poster.jpg","backdrop":"assets/movie-spider-man-no-way-home-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-10T14:21:05.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":634649,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Certified Fresh","rtAudience":97,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":1082660,"tmdb":7.947,"tmdbVotes":23505}},{"id":"movie-spirited-away-2001","title":"Spirited Away","year":2001,"type":"movie","runtimeSeconds":7472,"seasonCount":null,"communityRating":8.535,"criticRating":96,"genres":["Animation","Family","Fantasy"],"overview":"A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.","poster":"assets/movie-spirited-away-2001-poster.jpg","backdrop":"assets/movie-spirited-away-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:42.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":129,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":99,"rtAudienceLabel":"Upright","imdb":8.6,"imdbVotes":987658,"tmdb":8.536,"tmdbVotes":19176}},{"id":"movie-the-substance-2024","title":"The Substance","year":2024,"type":"movie","runtimeSeconds":8531,"seasonCount":null,"communityRating":7.135,"criticRating":89,"genres":["Horror","Science Fiction"],"overview":"A fading celebrity decides to use a black market drug, a cell-replicating substance that temporarily creates a younger, better version of herself.","poster":"assets/movie-the-substance-2024-poster.jpg","backdrop":"assets/movie-the-substance-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:22.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":933260,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":76,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":466542,"tmdb":7.13,"tmdbVotes":6746}},{"id":"movie-the-tale-of-the-princess-kaguya-2013","title":"The Tale of The Princess Kaguya","year":2013,"type":"movie","runtimeSeconds":8218,"seasonCount":null,"communityRating":8.1,"criticRating":100,"genres":["Animation","Drama","Fantasy"],"overview":"Found inside a shining stalk of bamboo by an old bamboo cutter and his wife, a tiny girl grows rapidly into an exquisite young lady. The mysterious young princess enthrals all who encounter her. But, ultimately, she\u2026","poster":"assets/movie-the-tale-of-the-princess-kaguya-2013-poster.jpg","backdrop":"assets/movie-the-tale-of-the-princess-kaguya-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-07T13:40:15.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149871,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":63574,"tmdb":8.083,"tmdbVotes":2079}},{"id":"movie-taxi-driver-1976","title":"Taxi Driver","year":1976,"type":"movie","runtimeSeconds":6831,"seasonCount":null,"communityRating":8.121,"criticRating":89,"genres":["Crime","Drama"],"overview":"Suffering from insomnia, disturbed loner Travis Bickle takes a job as a New York City cabbie, haunting the streets nightly, growing increasingly detached from reality as he dreams of cleaning up the filthy city.","poster":"assets/movie-taxi-driver-1976-poster.jpg","backdrop":"assets/movie-taxi-driver-1976-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:23:04.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":103,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":8.2,"imdbVotes":1046700,"tmdb":8.116,"tmdbVotes":13973}},{"id":"movie-triangle-of-sadness-2022","title":"Triangle of Sadness","year":2022,"type":"movie","runtimeSeconds":8833,"seasonCount":null,"communityRating":7.005,"criticRating":71,"genres":["Comedy","Drama"],"overview":"A celebrity model couple are invited on a luxury cruise for the uber-rich, helmed by an unhinged, alcoholic captain. What first appears Instagrammable ends catastrophically, leaving the survivors stranded on a desert\u2026","poster":"assets/movie-triangle-of-sadness-2022-poster.jpg","backdrop":"assets/movie-triangle-of-sadness-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:29:00.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":497828,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":80,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":221069,"tmdb":7,"tmdbVotes":3104}},{"id":"movie-tsotsi-2005","title":"Tsotsi","year":2005,"type":"movie","runtimeSeconds":5663,"seasonCount":null,"communityRating":6.937,"criticRating":82,"genres":["Crime","Drama"],"overview":"A young South African boy from the Johannesburg ghetto named Tsotsi, meaning Gangster, leaves home as a child to get away from his helpless parents. Now a teenage thug, Tsotsi finds a baby in the back seat of a car\u2026","poster":"assets/movie-tsotsi-2005-poster.jpg","backdrop":"assets/movie-tsotsi-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:58.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":868,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":82,"rtCriticsLabel":"Certified Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":32526,"tmdb":6.941,"tmdbVotes":372}},{"id":"movie-unknown-number-the-high-school-catfish-2025","title":"Unknown Number: The High School Catfish","year":2025,"type":"movie","runtimeSeconds":5691,"seasonCount":null,"communityRating":6.746,"criticRating":85,"genres":["Documentary"],"overview":"Vulgar, taunting texts blow up the phones of a teen and her boyfriend. Who's sending them \u2014 and why? This twisty documentary reveals the shocking answer.","poster":"assets/movie-unknown-number-the-high-school-catfish-2025-poster.jpg","backdrop":"assets/movie-unknown-number-the-high-school-catfish-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:32:10.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1517772,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":69,"rtAudienceLabel":"Upright","imdb":6.7,"imdbVotes":23039,"tmdb":6.737,"tmdbVotes":257}},{"id":"movie-the-uprising-2026","title":"The Uprising","year":2026,"type":"movie","runtimeSeconds":7684,"seasonCount":null,"communityRating":7.846,"criticRating":55,"genres":["Action","Drama","History"],"overview":"As war and plague devastate 14th-century England, a humble peasant ignites a rebellion, uniting an army of commoners to defy the King\u2019s might in a desperate fight for justice and survival.","poster":"assets/movie-the-uprising-2026-poster.jpg","backdrop":"assets/movie-the-uprising-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-02T16:37:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":977942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":65,"rtAudienceLabel":"Upright","imdb":6.2,"imdbVotes":6622,"tmdb":7.8,"tmdbVotes":404}},{"id":"movie-vice-2018","title":"Vice","year":2018,"type":"movie","runtimeSeconds":7948,"seasonCount":null,"communityRating":7.013,"criticRating":64,"genres":["Drama","Comedy","History"],"overview":"George W. Bush picks Dick Cheney, the CEO of Halliburton Co., to be his Republican running mate in the 2000 presidential election. No stranger to politics, Cheney's impressive r\u00e9sum\u00e9 includes stints as White House\u2026","poster":"assets/movie-vice-2018-poster.jpg","backdrop":"assets/movie-vice-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:28:30.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":429197,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":61,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":177299,"tmdb":7.014,"tmdbVotes":3723}},{"id":"movie-warriors-of-the-wind-1984","title":"Warriors of the Wind","year":1984,"type":"movie","runtimeSeconds":7022,"seasonCount":null,"communityRating":7.941,"criticRating":87,"genres":["Adventure","Animation","Fantasy"],"overview":"After a global war, the seaside kingdom known as the Valley of the Wind remains one of the last strongholds on Earth untouched by a poisonous jungle and the powerful insects that guard it. Led by the courageous\u2026","poster":"assets/movie-warriors-of-the-wind-1984-poster.jpg","backdrop":"assets/movie-warriors-of-the-wind-1984-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:22:20.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":81,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":200154,"tmdb":7.94,"tmdbVotes":4260}},{"id":"movie-we-bury-the-dead-2026","title":"We Bury the Dead","year":2026,"type":"movie","runtimeSeconds":5693,"seasonCount":null,"communityRating":6.153,"criticRating":87,"genres":["Horror","Thriller"],"overview":"After a catastrophic military disaster reanimates the dead, Ava searches for her missing husband amidst an increasingly hostile world.","poster":"assets/movie-we-bury-the-dead-2026-poster.jpg","backdrop":"assets/movie-we-bury-the-dead-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-08-26T05:36:00.4272328Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1198984,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":5.6,"imdbVotes":20693,"tmdb":6.137,"tmdbVotes":433}},{"id":"movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022","title":"White Hot: The Rise & Fall of Abercrombie & Fitch","year":2022,"type":"movie","runtimeSeconds":5187,"seasonCount":null,"communityRating":5.663,"criticRating":66,"genres":["Documentary"],"overview":"All the cool kids were wearing it. This documentary explores A&F's pop culture reign in the late '90s and early 2000s and how it thrived on exclusion.","poster":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-poster.jpg","backdrop":"assets/movie-white-hot-the-rise-fall-of-abercrombie-fitch-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:30:34.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":953371,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":29,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5655,"tmdb":5.7,"tmdbVotes":102}},{"id":"movie-the-wild-robot-2024","title":"The Wild Robot","year":2024,"type":"movie","runtimeSeconds":6101,"seasonCount":null,"communityRating":8.308,"criticRating":97,"genres":["Family","Animation","Science Fiction"],"overview":"After a shipwreck, an intelligent robot called Roz is stranded on an uninhabited island. To survive the harsh environment, Roz bonds with the island's animals and cares for an orphaned baby goose.","poster":"assets/movie-the-wild-robot-2024-poster.jpg","backdrop":"assets/movie-the-wild-robot-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:31:49.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1184918,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Certified Fresh","rtAudience":98,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":241134,"tmdb":8.309,"tmdbVotes":6819}},{"id":"movie-the-wind-rises-2013","title":"The Wind Rises","year":2013,"type":"movie","runtimeSeconds":8031,"seasonCount":null,"communityRating":7.784,"criticRating":88,"genres":["Animation","Drama","History"],"overview":"A lifelong love of flight inspires Japanese aviation engineer Jiro Horikoshi, whose storied career includes the creation of the A-6M World War II fighter plane.","poster":"assets/movie-the-wind-rises-2013-poster.jpg","backdrop":"assets/movie-the-wind-rises-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T10:24:38.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":149870,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":85,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":115756,"tmdb":7.783,"tmdbVotes":3536}},{"id":"movie-the-witcher-nightmare-of-the-wolf-2021","title":"The Witcher: Nightmare of the Wolf","year":2021,"type":"movie","runtimeSeconds":5015,"seasonCount":null,"communityRating":7.422,"criticRating":100,"genres":["Animation","Action","Fantasy"],"overview":"Escaping from poverty to become a witcher, Vesemir slays monsters for coin and glory, but when a new menace rises, he must face the demons of his past.","poster":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-poster.jpg","backdrop":"assets/movie-the-witcher-nightmare-of-the-wolf-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:42:03.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":666243,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.2,"imdbVotes":55759,"tmdb":7.407,"tmdbVotes":1159}},{"id":"movie-the-witcher-sirens-of-the-deep-2025","title":"The Witcher: Sirens of the Deep","year":2025,"type":"movie","runtimeSeconds":5517,"seasonCount":null,"communityRating":6.8,"criticRating":60,"genres":["Animation","Fantasy","Action"],"overview":"When human sailors are attacked by mysterious creatures of the deep, only one person can stop the war between land and sea: the Witcher, Geralt of Rivia.","poster":"assets/movie-the-witcher-sirens-of-the-deep-2025-poster.jpg","backdrop":"assets/movie-the-witcher-sirens-of-the-deep-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:03:26.0000000Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1203329,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":6.1,"imdbVotes":11799,"tmdb":6.741,"tmdbVotes":303}}],"shows":[{"id":"tv-adults-2025","title":"Adults","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.495,"criticRating":null,"genres":["Comedy"],"overview":"A group of twenty-somethings in New York trying to be good people, despite being neither \"good\" nor \"people\" yet.","poster":"assets/tv-adults-2025-poster.jpg","backdrop":"assets/tv-adults-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T21:01:41.1814765Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":260458,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":102}},{"id":"tv-american-crime-story-2016","title":"American Crime Story","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.573,"criticRating":null,"genres":["Crime","Drama"],"overview":"An anthology series centered around some of history's most famous criminal investigations.","poster":"assets/tv-american-crime-story-2016-poster.jpg","backdrop":"assets/tv-american-crime-story-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0490488Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":64513,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.585,"tmdbVotes":1094}},{"id":"tv-american-primeval-2025","title":"American Primeval","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.691,"criticRating":null,"genres":["Western","Drama","Action & Adventure"],"overview":"A mother and son fleeing from their past form a found family while confronting a harsh landscape of freedom and cruelty in the American West.","poster":"assets/tv-american-primeval-2025-poster.jpg","backdrop":"assets/tv-american-primeval-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0573221Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":215866,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":71,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.741,"tmdbVotes":538}},{"id":"tv-arrested-development-2003","title":"Arrested Development","year":2003,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":7.925,"criticRating":null,"genres":["Comedy"],"overview":"The story of a wealthy family that lost everything, and the one son who had no choice but to keep them all together.","poster":"assets/tv-arrested-development-2003-poster.jpg","backdrop":"assets/tv-arrested-development-2003-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0513048Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":4589,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.906,"tmdbVotes":1793}},{"id":"tv-avatar-the-last-airbender-2005","title":"Avatar: The Last Airbender","year":2005,"type":"tv","runtimeSeconds":1440,"seasonCount":3,"communityRating":8.78,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a war-torn world of elemental magic, a young boy reawakens to undertake a dangerous mystic quest to fulfill his destiny as the Avatar, and bring peace to the world.","poster":"assets/tv-avatar-the-last-airbender-2005-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0504536Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":246,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.787,"tmdbVotes":5215}},{"id":"tv-b-b-vol-liefde-2021","title":"B&B Vol Liefde","year":2021,"type":"tv","runtimeSeconds":3720,"seasonCount":1,"communityRating":7.2,"criticRating":null,"genres":["Reality"],"overview":"B&B Vol Liefde is a heartwarming adventure, full of romance, excitement, and unforgettable moments. Adventurous Dutch B&B owners abroad throw their doors wide open and let their hearts speak, determined to find that\u2026","poster":"assets/tv-b-b-vol-liefde-2021-poster.jpg","backdrop":"assets/tv-b-b-vol-liefde-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-31T22:30:11.129657Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":124428,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":10}},{"id":"tv-the-bear-2022","title":"The Bear","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.139,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Carmy, a young fine-dining chef, comes home to Chicago to run his family sandwich shop. As he fights to transform the shop and himself, he works alongside a rough-around-the-edges crew that ultimately reveal\u2026","poster":"assets/tv-the-bear-2022-poster.jpg","backdrop":"assets/tv-the-bear-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0467371Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":136315,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.158,"tmdbVotes":2025}},{"id":"tv-blue-planet-ii-2017","title":"Blue Planet II","year":2017,"type":"tv","runtimeSeconds":3600,"seasonCount":1,"communityRating":8.64,"criticRating":null,"genres":["Documentary","Family"],"overview":"There is nowhere more powerful and unforgiving yet more beautiful and compelling than the ocean. Join us and explore the greatest yet least known parts of our planet.","poster":"assets/tv-blue-planet-ii-2017-poster.jpg","backdrop":"assets/tv-blue-planet-ii-2017-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0634849Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":74313,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":380}},{"id":"tv-breeders-2020","title":"Breeders","year":2020,"type":"tv","runtimeSeconds":1500,"seasonCount":4,"communityRating":6.8,"criticRating":null,"genres":["Comedy"],"overview":"Exposing the parental-paradox that it is possible, in the very same moment, to love your child to the horizon of the universe, while being apoplectically angry enough to want to send them there.","poster":"assets/tv-breeders-2020-poster.jpg","backdrop":"assets/tv-breeders-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:54:21.8885435Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":93290,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":104}},{"id":"tv-chernobyl-2019","title":"Chernobyl","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.718,"criticRating":null,"genres":["Drama"],"overview":"The true story of one of the worst man-made catastrophes in history: the catastrophic nuclear accident at Chernobyl. A tale of the brave men and women who sacrificed to save Europe from unimaginable disaster.","poster":"assets/tv-chernobyl-2019-poster.jpg","backdrop":"assets/tv-chernobyl-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0469903Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":87108,"artwork":{"posterKind":"poster","backdropKind":"center_crop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":8496}},{"id":"tv-community-2009","title":"Community","year":2009,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":7.997,"criticRating":null,"genres":["Comedy"],"overview":"Follow the lives of a group of students at what is possibly the world\u2019s worst community college in the fictional locale of Greendale, Colorado.","poster":"assets/tv-community-2009-poster.jpg","backdrop":"assets/tv-community-2009-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0531416Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":18347,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.994,"tmdbVotes":2226}},{"id":"tv-dead-end-job-2026","title":"Dead-End Job","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.7,"criticRating":null,"genres":["Mystery"],"overview":"Drowning in debt, a young man takes lucrative part-time jobs from a mysterious agency \u2014 only to find his hustle for more money turning nightmarish.","poster":"assets/tv-dead-end-job-2026-poster.jpg","backdrop":"assets/tv-dead-end-job-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:58:01.8352857Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":289517,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":38}},{"id":"tv-dopesick-2021","title":"Dopesick","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.015,"criticRating":null,"genres":["Crime","Drama"],"overview":"The story of how one company triggered the worst drug epidemic in American history. Look into the epicenter of America's struggle with opioid addiction, from a distressed Virginia mining community, to the hallways of\u2026","poster":"assets/tv-dopesick-2021-poster.jpg","backdrop":"assets/tv-dopesick-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0580168Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":110695,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.035,"tmdbVotes":565}},{"id":"tv-dune-prophecy-2024","title":"Dune: Prophecy","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.213,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama","Action & Adventure"],"overview":"Ten thousand years before the ascension of Paul Atreides, sisters Valya and Tula Harkonnen establish the fabled sect and female order that would become known as the Bene Gesserit to control the future of humankind.","poster":"assets/tv-dune-prophecy-2024-poster.jpg","backdrop":"assets/tv-dune-prophecy-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T18:23:01.2908568Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":90228,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":64,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.213,"tmdbVotes":677}},{"id":"tv-east-of-eden-2026","title":"East of Eden","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.931,"criticRating":null,"genres":["Drama","Crime"],"overview":"In the early 1900s, a fiercely independent woman drives a rift between two brothers, igniting a saga that unfolds across two generations.","poster":"assets/tv-east-of-eden-2026-poster.jpg","backdrop":"assets/tv-east-of-eden-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-23T18:07:11.3965096Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":258165,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.982,"tmdbVotes":139}},{"id":"tv-fallout-2024","title":"Fallout","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.124,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"The story of haves and have-nots in a world in which there's almost nothing left to have. 200 years after the apocalypse, the gentle denizens of luxury fallout shelters are forced to return to the irradiated\u2026","poster":"assets/tv-fallout-2024-poster.jpg","backdrop":"assets/tv-fallout-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0669262Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":106379,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.106,"tmdbVotes":3233}},{"id":"tv-fawlty-towers-1975","title":"Fawlty Towers","year":1975,"type":"tv","runtimeSeconds":1800,"seasonCount":2,"communityRating":8.295,"criticRating":null,"genres":["Comedy"],"overview":"Owner Basil Fawlty, his wife Sybil, a chambermaid Polly, and Spanish waiter Manuel attempt to run their hotel amidst farcical situations and an array of demanding guests.","poster":"assets/tv-fawlty-towers-1975-poster.jpg","backdrop":"assets/tv-fawlty-towers-1975-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-19T23:26:39.7658344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2207,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.296,"tmdbVotes":867}},{"id":"tv-the-final-problem-2026","title":"The Final Problem","year":2026,"type":"tv","runtimeSeconds":3420,"seasonCount":1,"communityRating":7.041,"criticRating":null,"genres":["Crime","Mystery"],"overview":"In the spring of 1959, a retired actor, who previously played Sherlock Holmes, must investigate a murder at a small hotel on a tiny island near Mallorca.","poster":"assets/tv-the-final-problem-2026-poster.jpg","backdrop":"assets/tv-the-final-problem-2026-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-11-04T19:16:12.4822791Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":290720,"artwork":{"posterKind":"center_crop","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.041,"tmdbVotes":49}},{"id":"tv-the-four-seasons-2025","title":"The Four Seasons","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":6.879,"criticRating":null,"genres":["Comedy"],"overview":"The decades-long friendship between three married couples is tested when one divorces, complicating their tradition of quarterly weekend getaways.","poster":"assets/tv-the-four-seasons-2025-poster.jpg","backdrop":"assets/tv-the-four-seasons-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-28T18:36:42.5326384Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":243316,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.892,"tmdbVotes":204}},{"id":"tv-the-get-down-2016","title":"The Get Down","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.3,"criticRating":null,"genres":["Drama"],"overview":"In 1977 New York City, the talented and soulful youth of the South Bronx chase dreams and breakneck beats to transform music history.","poster":"assets/tv-the-get-down-2016-poster.jpg","backdrop":"assets/tv-the-get-down-2016-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0497737Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":65345,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":196}},{"id":"tv-how-to-change-your-mind-2022","title":"How to Change Your Mind","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.76,"criticRating":null,"genres":["Documentary","Mystery"],"overview":"Author Michael Pollan leads the way in this docuseries exploring the history and uses of psychedelics, including LSD, psilocybin, MDMA and mescaline.","poster":"assets/tv-how-to-change-your-mind-2022-poster.jpg","backdrop":"assets/tv-how-to-change-your-mind-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0601399Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":204304,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":53}},{"id":"tv-interrogation-2020","title":"Interrogation","year":2020,"type":"tv","runtimeSeconds":2700,"seasonCount":1,"communityRating":6.788,"criticRating":null,"genres":["Drama","Crime"],"overview":"Based on a true story that spanned more than 30 years in which a young man was charged and convicted of brutally murdering his mother. Each episode is structured around an interrogation taken directly from the real\u2026","poster":"assets/tv-interrogation-2020-poster.jpg","backdrop":"assets/tv-interrogation-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T05:52:06.1068207Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":96488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.9,"tmdbVotes":54}},{"id":"tv-it-s-always-sunny-in-philadelphia-2005","title":"It's Always Sunny in Philadelphia","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":18,"communityRating":8.2,"criticRating":null,"genres":["Comedy"],"overview":"Four egocentric friends run a neighborhood Irish pub in Philadelphia and try to find their way through the adult world of work and relationships. Unfortunately, their warped views and precarious judgments often lead\u2026","poster":"assets/tv-it-s-always-sunny-in-philadelphia-2005-poster.jpg","backdrop":"assets/tv-it-s-always-sunny-in-philadelphia-2005-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0485774Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":2710,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.253,"tmdbVotes":1457}},{"id":"tv-a-knight-of-the-seven-kingdoms-2026","title":"A Knight of the Seven Kingdoms","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.431,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy","Action & Adventure"],"overview":"A century before the events of Game of Thrones, two unlikely heroes wandered Westeros: a young, naive but courageous knight, Ser Duncan the Tall, and his diminutive squire, Egg. Set in an age when the Targaryen line\u2026","poster":"assets/tv-a-knight-of-the-seven-kingdoms-2026-poster.jpg","backdrop":"assets/tv-a-knight-of-the-seven-kingdoms-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0493344Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":224372,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":1266}},{"id":"tv-landman-2024","title":"Landman","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.194,"criticRating":null,"genres":["Drama"],"overview":"Set in the proverbial boomtowns of West-Texas and a modern-day tale of fortune-seeking in the world of oil rigs, the series is an upstairs/downstairs story of roughnecks and wildcat billionaires that are fueling a\u2026","poster":"assets/tv-landman-2024-poster.jpg","backdrop":"assets/tv-landman-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-10-22T21:38:18.0973875Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":157741,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.209,"tmdbVotes":668}},{"id":"tv-the-last-of-us-2023","title":"The Last of Us","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.419,"criticRating":null,"genres":["Drama"],"overview":"Twenty years after modern civilization has been destroyed, Joel, a hardened survivor, is hired to smuggle Ellie, a 14-year-old girl, out of an oppressive quarantine zone. What starts as a small job soon becomes a\u2026","poster":"assets/tv-the-last-of-us-2023-poster.jpg","backdrop":"assets/tv-the-last-of-us-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0640955Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":100088,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.4,"tmdbVotes":7485}},{"id":"tv-last-samurai-standing-2025","title":"Last Samurai Standing","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.805,"criticRating":null,"genres":["Action & Adventure","Drama"],"overview":"In the early Meiji era, Shujiro, once known as an undefeated samurai, decides to participate in a deadly survival game to save his family and villagers.","poster":"assets/tv-last-samurai-standing-2025-poster.jpg","backdrop":"assets/tv-last-samurai-standing-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0622526Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":252193,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.812,"tmdbVotes":271}},{"id":"tv-lord-of-the-flies-2026","title":"Lord of the Flies","year":2026,"type":"tv","runtimeSeconds":3480,"seasonCount":1,"communityRating":7.571,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"After a plane crash, a group of schoolboys find themselves stranded on a tropical island without adults. Ralph is elected leader and, with the help of the intelligent Piggy, strives to maintain order and\u2026","poster":"assets/tv-lord-of-the-flies-2026-poster.jpg","backdrop":"assets/tv-lord-of-the-flies-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0645648Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270572,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":91,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.58,"tmdbVotes":138}},{"id":"tv-the-lord-of-the-rings-the-rings-of-power-2022","title":"The Lord of the Rings: The Rings of Power","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.233,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Beginning in a time of relative peace, we follow an ensemble cast of characters as they confront the re-emergence of evil to Middle-earth. From the darkest depths of the Misty Mountains, to the majestic forests of\u2026","poster":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-poster.jpg","backdrop":"assets/tv-the-lord-of-the-rings-the-rings-of-power-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0618904Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":84773,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":4003}},{"id":"tv-love-death-robots-2019","title":"Love, Death & Robots","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":8.21,"criticRating":null,"genres":["Animation","Sci-Fi & Fantasy"],"overview":"Terrifying creatures, wicked surprises and dark comedy converge in this NSFW anthology of animated stories presented by Tim Miller and David Fincher.","poster":"assets/tv-love-death-robots-2019-poster.jpg","backdrop":"assets/tv-love-death-robots-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0460964Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86831,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.203,"tmdbVotes":4238}},{"id":"tv-maniac-2018","title":"Maniac","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.338,"criticRating":null,"genres":["Comedy","Drama","Sci-Fi & Fantasy"],"overview":"Two strangers are drawn to a mysterious pharmaceutical trial that will, they're assured, with no complications or side-effects whatsoever, solve all of their problems, permanently. Things do not go as planned.","poster":"assets/tv-maniac-2018-poster.jpg","backdrop":"assets/tv-maniac-2018-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-20T19:42:30.955548Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":73411,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.323,"tmdbVotes":1073}},{"id":"tv-mussolini-son-of-the-century-2024","title":"Mussolini: Son of the Century","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.135,"criticRating":null,"genres":["Drama","War & Politics"],"overview":"The rise of Benito Mussolini and the birth of fascism in Italy, chronicling a country\u2019s surrender to dictatorship and the relentless ascent of a man who rose from his ashes time and again.","poster":"assets/tv-mussolini-son-of-the-century-2024-poster.jpg","backdrop":"assets/tv-mussolini-son-of-the-century-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0586588Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":212333,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":179}},{"id":"tv-the-night-manager-2016","title":"The Night Manager","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":7.74,"criticRating":null,"genres":["Drama","Mystery","Crime"],"overview":"Former British soldier Jonathan Pine navigates the shadowy recesses of Whitehall and Washington where an unholy alliance operates between the intelligence community and the secret arms trade. To infiltrate the inner\u2026","poster":"assets/tv-the-night-manager-2016-poster.jpg","backdrop":"assets/tv-the-night-manager-2016-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0663672Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61859,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":1422}},{"id":"tv-one-piece-2023","title":"ONE PIECE","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.118,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"With his straw hat and ragtag crew, young pirate Monkey D. Luffy goes on an epic voyage for treasure.","poster":"assets/tv-one-piece-2023-poster.jpg","backdrop":"assets/tv-one-piece-2023-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0439378Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111110,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.104,"tmdbVotes":1988}},{"id":"tv-only-murders-in-the-building-2021","title":"Only Murders in the Building","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.438,"criticRating":null,"genres":["Comedy","Mystery","Crime"],"overview":"Three strangers share an obsession with true crime and suddenly find themselves wrapped up in one.","poster":"assets/tv-only-murders-in-the-building-2021-poster.jpg","backdrop":"assets/tv-only-murders-in-the-building-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0594614Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":107113,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.398,"tmdbVotes":2333}},{"id":"tv-over-the-garden-wall-2014","title":"Over the Garden Wall","year":2014,"type":"tv","runtimeSeconds":720,"seasonCount":1,"communityRating":8.6,"criticRating":null,"genres":["Mystery","Sci-Fi & Fantasy","Animation"],"overview":"Two brothers, Wirt and Greg, find themselves lost in the Unknown; a strange forest adrift in time. With the help of a wise old Woodsman and a foul-tempered bluebird named Beatrice, Wirt and Greg must travel across\u2026","poster":"assets/tv-over-the-garden-wall-2014-poster.jpg","backdrop":"assets/tv-over-the-garden-wall-2014-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0455757Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":61617,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.6,"tmdbVotes":1854}},{"id":"tv-peaky-blinders-2013","title":"Peaky Blinders","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":6,"communityRating":8.5,"criticRating":null,"genres":["Drama","Crime"],"overview":"A gangster family epic set in 1919 Birmingham, England and centered on a gang who sew razor blades in the peaks of their caps, and their fierce boss Tommy Shelby, who means to move up in the world.","poster":"assets/tv-peaky-blinders-2013-poster.jpg","backdrop":"assets/tv-peaky-blinders-2013-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0539185Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":60574,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.5,"tmdbVotes":11506}},{"id":"tv-the-pitt-2025","title":"The Pitt","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.729,"criticRating":null,"genres":["Drama"],"overview":"The staff of Pittsburgh's Trauma Medical Center work around the clock to save lives in an overcrowded and underfunded emergency department.","poster":"assets/tv-the-pitt-2025-poster.jpg","backdrop":"assets/tv-the-pitt-2025-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0582942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":250307,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.731,"tmdbVotes":1046}},{"id":"tv-pluribus-2025","title":"Pluribus","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.936,"criticRating":null,"genres":["Drama","Sci-Fi & Fantasy"],"overview":"The most miserable person on Earth must save the world from happiness.","poster":"assets/tv-pluribus-2025-poster.jpg","backdrop":"assets/tv-pluribus-2025-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0674462Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":225171,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":99,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.921,"tmdbVotes":1191}},{"id":"tv-ripley-2024","title":"RIPLEY","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.911,"criticRating":null,"genres":["Drama"],"overview":"A grifter in 1960s New York is hired to convince a wealthy man's son to return home from Italy and begins a life of deceit, fraud and murder.","poster":"assets/tv-ripley-2024-poster.jpg","backdrop":"assets/tv-ripley-2024-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0658942Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":94028,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.909,"tmdbVotes":453}},{"id":"tv-say-nothing-2024","title":"Say Nothing","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":7.462,"criticRating":null,"genres":["Drama","Crime"],"overview":"Through the eyes of various Irish Republican Army (IRA) members, explore the extremes some people will go to in the name of their beliefs, the way a deeply divided society can suddenly tip over into armed conflict,\u2026","poster":"assets/tv-say-nothing-2024-poster.jpg","backdrop":"assets/tv-say-nothing-2024-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0499752Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":245256,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":94,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.475,"tmdbVotes":122}},{"id":"tv-severance-2022","title":"Severance","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":2,"communityRating":8.396,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"Mark leads a team of office workers whose memories have been surgically divided between their work and personal lives. When a mysterious colleague appears outside of work, it begins a journey to discover the truth\u2026","poster":"assets/tv-severance-2022-poster.jpg","backdrop":"assets/tv-severance-2022-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0598478Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":95396,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.389,"tmdbVotes":3095}},{"id":"tv-six-feet-under-2001","title":"Six Feet Under","year":2001,"type":"tv","runtimeSeconds":null,"seasonCount":5,"communityRating":8.115,"criticRating":null,"genres":["Drama"],"overview":"When death is your business, what is your life? For the Fisher family, the world outside of their family-owned funeral home continues to be at least as challenging as\u2014and far less predictable than\u2014the one inside.","poster":"assets/tv-six-feet-under-2001-poster.jpg","backdrop":"assets/tv-six-feet-under-2001-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-09-26T04:04:05.108787Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":1274,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":81,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.1,"tmdbVotes":1192}},{"id":"tv-undercover-2019","title":"Undercover","year":2019,"type":"tv","runtimeSeconds":3000,"seasonCount":3,"communityRating":7.5,"criticRating":null,"genres":["Crime","Drama"],"overview":"Undercover agents infiltrate a drug kingpin's operation by posing as a couple at the campground where he spends his weekends. Inspired by real events.","poster":"assets/tv-undercover-2019-poster.jpg","backdrop":"assets/tv-undercover-2019-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0570153Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86942,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":186}},{"id":"tv-upload-2020","title":"Upload","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.781,"criticRating":null,"genres":["Comedy","Sci-Fi & Fantasy","Drama"],"overview":"In 2033, people who are near death can be \u201cuploaded\u201d into virtual reality hotels run by 6 tech firms. Cash-strapped Nora lives in Brooklyn and works customer service for the luxurious \u201cLakeview\u201d digital afterlife.\u2026","poster":"assets/tv-upload-2020-poster.jpg","backdrop":"assets/tv-upload-2020-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0566967Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":86248,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":87,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.757,"tmdbVotes":1511}},{"id":"tv-the-walk-in-2022","title":"The Walk-In","year":2022,"type":"tv","runtimeSeconds":2760,"seasonCount":1,"communityRating":6.9,"criticRating":null,"genres":["Drama","Crime"],"overview":"Forced to flee the UK and go into hiding in the 1990s, when he became a mole from within the BNP, Matthew Collins returned to Britain to make a new life for himself, working with young white men in the fight against\u2026","poster":"assets/tv-the-walk-in-2022-poster.jpg","backdrop":"assets/tv-the-walk-in-2022-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0654569Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":210664,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":50}},{"id":"tv-the-white-lotus-2021","title":"The White Lotus","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.596,"criticRating":null,"genres":["Comedy","Drama","Mystery"],"overview":"Follow the exploits of various guests and employees at an exclusive tropical resort over the span of a week as with each passing day, a darker complexity emerges in these picture-perfect travelers, the hotel\u2019s\u2026","poster":"assets/tv-the-white-lotus-2021-poster.jpg","backdrop":"assets/tv-the-white-lotus-2021-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0546438Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":111803,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":90,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.599,"tmdbVotes":1604}},{"id":"tv-widow-s-bay-2026","title":"Widow's Bay","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":1,"communityRating":8.172,"criticRating":null,"genres":["Drama","Comedy","Mystery"],"overview":"The mayor of a New England town resolves to turn it into the next tourist hot spot, despite local warnings that it's cursed.","poster":"assets/tv-widow-s-bay-2026-poster.jpg","backdrop":"assets/tv-widow-s-bay-2026-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-20T10:27:12.7003979Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":270476,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":98,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8,"tmdbVotes":585}},{"id":"tv-the-witcher-2019","title":"The Witcher","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":4,"communityRating":7.905,"criticRating":null,"genres":["Drama","Action & Adventure"],"overview":"Geralt of Rivia, a mutated monster-hunter for hire, journeys toward his destiny in a turbulent world where people often prove more wicked than beasts.","poster":"assets/tv-the-witcher-2019-poster.jpg","backdrop":"assets/tv-the-witcher-2019-backdrop.jpg","played":true,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0552369Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":71912,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.88,"tmdbVotes":6997}},{"id":"tv-yellowjackets-2021","title":"Yellowjackets","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":3,"communityRating":7.449,"criticRating":null,"genres":["Drama","Mystery"],"overview":"This equal parts survival epic, psychological horror story and coming-of-age drama tells the saga of a team of wildly talented high school girls soccer players who become the (un)lucky survivors of a plane crash deep\u2026","poster":"assets/tv-yellowjackets-2021-poster.jpg","backdrop":"assets/tv-yellowjackets-2021-backdrop.jpg","played":false,"playCount":0,"resumeSeconds":0.0,"progress":0,"addedAt":"2026-07-17T15:45:22.0577612Z","lastPlayedAt":null,"availability":"in_library","availabilityEvidence":"viewer_scoped_jellyfin_library","source":"jellyfin_library","tmdbId":117488,"artwork":{"posterKind":"poster","backdropKind":"backdrop"},"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.431,"tmdbVotes":1070}}],"anime":[],"discover":[{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}]}],"seeAll":{"trending":[{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}},{"id":"movie-across-the-hall-2009","title":"Across the Hall","year":2009,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Mystery"],"overview":"Convinced that his fiancee is cheating on him, a man follows her to a hotel and calls his best friend to help him avoid a calamity.","poster":"assets/movie-across-the-hall-2009-poster.jpg","backdrop":"assets/movie-across-the-hall-2009-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":21469,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":39,"rtAudienceLabel":"Spilled","imdb":5.8,"imdbVotes":5130,"tmdb":6,"tmdbVotes":129}},{"id":"movie-addicted-2014","title":"Addicted","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A gallerist risks her family and flourishing career when she enters into an affair with a talented painter and slowly loses control of her life.","poster":"assets/movie-addicted-2014-poster.jpg","backdrop":"assets/movie-addicted-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":235271,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":51,"rtAudienceLabel":"Spilled","imdb":5,"imdbVotes":13131,"tmdb":5.928,"tmdbVotes":466}},{"id":"movie-adulterers-2016","title":"Adulterers","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Drama"],"overview":"On a steamy New Orleans afternoon, Sam Deuprey decides to surprise his beautiful wife, Ashley, for their anniversary and comes home early. But Ashley is not alone in the bedroom. She and her lover, Damien are about\u2026","poster":"assets/movie-adulterers-2016-poster.jpg","backdrop":"assets/movie-adulterers-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":376004,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":37,"rtAudienceLabel":"Spilled","imdb":5.1,"imdbVotes":2144,"tmdb":6.2,"tmdbVotes":116}},{"id":"movie-alone-with-her-2006","title":"Alone With Her","year":2006,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"The harrowing story of a disturbed young man's attempts to win the affections of an unsuspecting young woman. When Doug first sees Amy, he instantly falls for her and begins to watch her every move, going so far as\u2026","poster":"assets/movie-alone-with-her-2006-poster.jpg","backdrop":"assets/movie-alone-with-her-2006-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18206,"ratings":{"rtCritics":70,"rtCriticsLabel":"Fresh","rtAudience":46,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":4359,"tmdb":5.9,"tmdbVotes":94}},{"id":"movie-awake-2007","title":"Awake","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"While undergoing heart surgery, a man experiences a phenomenon called \u2018anesthetic awareness\u2019, which leaves him awake but paralyzed throughout the operation. As various obstacles present themselves, his wife must make\u2026","poster":"assets/movie-awake-2007-poster.jpg","backdrop":"assets/movie-awake-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13483,"ratings":{"rtCritics":23,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":89389,"tmdb":6.586,"tmdbVotes":1649}},{"id":"movie-black-bear-2020","title":"Black Bear","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"At a remote lake house in the Adirondack Mountains, a couple entertains an out-of-town guest looking for inspiration in her filmmaking. The group quickly falls into a calculated game of desire, manipulation, and\u2026","poster":"assets/movie-black-bear-2020-poster.jpg","backdrop":"assets/movie-black-bear-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":624788,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.5,"imdbVotes":18408,"tmdb":6.337,"tmdbVotes":315}},{"id":"movie-butterfly-on-a-wheel-2007","title":"Butterfly on a Wheel","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Thriller"],"overview":"A sociopathic kidnapper methodically pushes a desperate pair of parents to their absolute breaking point.","poster":"assets/movie-butterfly-on-a-wheel-2007-poster.jpg","backdrop":"assets/movie-butterfly-on-a-wheel-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13250,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":35409,"tmdb":6.457,"tmdbVotes":595}},{"id":"movie-careful-what-you-wish-for-2015","title":"Careful What You Wish For","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"A guy gets more than he bargained for after entering into an affair with the wife of an investment banker. Soon, a suspicious death and substantial life insurance policy embroil him in a scandal.","poster":"assets/movie-careful-what-you-wish-for-2015-poster.jpg","backdrop":"assets/movie-careful-what-you-wish-for-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":317744,"ratings":{"rtCritics":17,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":9452,"tmdb":6.129,"tmdbVotes":446}},{"id":"movie-crime-of-passion-1956","title":"Crime of Passion","year":1956,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Thriller"],"overview":"Kathy leaves the newspaper business to marry homicide detective Bill, but is frustrated by his lack of ambition and the banality of life in the suburbs. Her drive to advance Bill's career soon takes her down a\u2026","poster":"assets/movie-crime-of-passion-1956-poster.jpg","backdrop":"assets/movie-crime-of-passion-1956-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1920,"ratings":{"rtCritics":80,"rtCriticsLabel":"Fresh","rtAudience":34,"rtAudienceLabel":"Spilled","imdb":6.4,"imdbVotes":3750,"tmdb":6.1,"tmdbVotes":57}},{"id":"movie-deep-water-2022","title":"Deep Water","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"A well-to-do husband who allows his wife to have affairs in order to avoid a divorce becomes a prime suspect in the disappearance of her lovers.","poster":"assets/movie-deep-water-2022-poster.jpg","backdrop":"assets/movie-deep-water-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":619979,"ratings":{"rtCritics":35,"rtCriticsLabel":"Rotten","rtAudience":25,"rtAudienceLabel":"Spilled","imdb":5.5,"imdbVotes":74098,"tmdb":5.834,"tmdbVotes":1755}},{"id":"movie-derailed-2005","title":"Derailed","year":2005,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"When two married business executives having an affair are blackmailed by a violent criminal, they are forced to turn the tables on him to save their families.","poster":"assets/movie-derailed-2005-poster.jpg","backdrop":"assets/movie-derailed-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":8999,"ratings":{"rtCritics":20,"rtCriticsLabel":"Rotten","rtAudience":58,"rtAudienceLabel":"Spilled","imdb":6.6,"imdbVotes":79563,"tmdb":6.405,"tmdbVotes":1193}},{"id":"movie-die-my-love-2025","title":"Die My Love","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"After inheriting a remote Montana house, Jackson moves there from New York with his partner Grace, and the couple soon welcome a child. As Jackson becomes increasingly absent and rural isolation sets in, Grace\u2026","poster":"assets/movie-die-my-love-2025-poster.jpg","backdrop":"assets/movie-die-my-love-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1033148,"ratings":{"rtCritics":74,"rtCriticsLabel":"Certified Fresh","rtAudience":45,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":50664,"tmdb":6.1,"tmdbVotes":683}},{"id":"movie-dream-house-2011","title":"Dream House","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Drama","Thriller"],"overview":"Publisher Will Atenton quits a lucrative job in New York to relocate his wife, Libby, and their daughters to a quaint town in New England. However, as they settle into their home the Atentons discover that a woman\u2026","poster":"assets/movie-dream-house-2011-poster.jpg","backdrop":"assets/movie-dream-house-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69668,"ratings":{"rtCritics":7,"rtCriticsLabel":"Rotten","rtAudience":36,"rtAudienceLabel":"Spilled","imdb":6,"imdbVotes":71386,"tmdb":6.212,"tmdbVotes":1480}},{"id":"movie-dream-lover-1994","title":"Dream Lover","year":1994,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Thriller"],"overview":"Not long after they cross paths at an art gallery, architect Ray Reardon and hypnotically sensual Lena are married with children. But as strange incidents occur, Ray begins to realize he may not really know the woman\u2026","poster":"assets/movie-dream-lover-1994-poster.jpg","backdrop":"assets/movie-dream-lover-1994-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":18190,"ratings":{"rtCritics":57,"rtCriticsLabel":"Rotten","rtAudience":38,"rtAudienceLabel":"Spilled","imdb":6.2,"imdbVotes":7865,"tmdb":6.844,"tmdbVotes":167}},{"id":"movie-drop-2025","title":"Drop","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Thriller"],"overview":"Violet, a widowed mother on her first date in years, arrives at an upscale restaurant where she is relieved that her date, Henry, is more charming and handsome than she expected. But their chemistry begins to curdle\u2026","poster":"assets/movie-drop-2025-poster.jpg","backdrop":"assets/movie-drop-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1249213,"ratings":{"rtCritics":83,"rtCriticsLabel":"Certified Fresh","rtAudience":79,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":61018,"tmdb":6.275,"tmdbVotes":1023}},{"id":"movie-eastern-promises-2007","title":"Eastern Promises","year":2007,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","Crime","Mystery"],"overview":"A Russian teenager living in London dies during childbirth but leaves clues in her diary that could tie her child to a rape involving a violent Russian mob family.","poster":"assets/movie-eastern-promises-2007-poster.jpg","backdrop":"assets/movie-eastern-promises-2007-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2252,"ratings":{"rtCritics":89,"rtCriticsLabel":"Certified Fresh","rtAudience":83,"rtAudienceLabel":"Upright","imdb":7.6,"imdbVotes":281015,"tmdb":7.349,"tmdbVotes":3900}},{"id":"movie-faces-1968","title":"Faces","year":1968,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Middle-aged suburban husband Richard abruptly tells his wife, Maria, that he wants a divorce. As Richard takes up with a younger woman, Maria enjoys a night on the town with her friends and meets a younger man. As\u2026","poster":"assets/movie-faces-1968-poster.jpg","backdrop":"assets/movie-faces-1968-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":753,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":86,"rtAudienceLabel":"Upright","imdb":7.4,"imdbVotes":12950,"tmdb":7.093,"tmdbVotes":227}},{"id":"movie-fatale-2020","title":"Fatale","year":2020,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Thriller"],"overview":"After a wild one-night stand, successful sports agent Derrick Tyler watches his perfect life slowly disappear when he discovers the sexy and mysterious woman he risked everything for is determined police detective\u2026","poster":"assets/movie-fatale-2020-poster.jpg","backdrop":"assets/movie-fatale-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":549514,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":5.5,"imdbVotes":16392,"tmdb":6,"tmdbVotes":401}},{"id":"tv-56-days-2026","title":"56 Days","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"When an unidentified body is found in a luxury apartment linked to Oliver Kennedy and his girlfriend Ciara Wyse, Detectives Lee Reardon and Karl Connolly reconstruct the couple's deadly romance across the past 56 days.","poster":"assets/tv-56-days-2026-poster.jpg","backdrop":"assets/tv-56-days-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":241372,"ratings":{"rtCritics":60,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.388,"tmdbVotes":245}},{"id":"tv-absolutely-fabulous-1992","title":"Absolutely Fabulous","year":1992,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Set in the world of fashion and PR, immature fun-loving mother Edina Monsoon and her best friend Patsy drive Eddie's sensible daughter, Saffron, up the wall with their constant drug abuse and outrageous selfishness.\u2026","poster":"assets/tv-absolutely-fabulous-1992-poster.jpg","backdrop":"assets/tv-absolutely-fabulous-1992-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":4493,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":198}},{"id":"tv-the-afterparty-2022","title":"The Afterparty","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Mystery","Comedy","Crime"],"overview":"Nothing is as it seems in this hilarious murder mystery where each suspect's story is told through a different film genre.","poster":"assets/tv-the-afterparty-2022-poster.jpg","backdrop":"assets/tv-the-afterparty-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":106454,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.071,"tmdbVotes":245}},{"id":"tv-agatha-christie-s-marple-2004","title":"Agatha Christie's Marple","year":2004,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime","Mystery"],"overview":"The adventures of Miss Jane Marple, an elderly spinster living in the quiet little village of St Mary Mead. During her many visits to friends and relatives in other villages, Miss Marple often stumbles upon\u2026","poster":"assets/tv-agatha-christie-s-marple-2004-poster.jpg","backdrop":"assets/tv-agatha-christie-s-marple-2004-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":6127,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":152}},{"id":"tv-all-american-homecoming-2022","title":"All American: Homecoming","year":2022,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama"],"overview":"Follow the journeys of Simone Hicks, a tennis hopeful from Beverly Hills, and elite baseball player Damon Sims from Chicago, as they navigate life at the prestigious HBCU Bringston University.","poster":"assets/tv-all-american-homecoming-2022-poster.jpg","backdrop":"assets/tv-all-american-homecoming-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":153723,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.3,"tmdbVotes":70}},{"id":"tv-and-just-like-that-2021","title":"And Just Like That\u2026","year":2021,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"This new chapter of \u201cSex and the City\u201d follows Carrie, Miranda and Charlotte as they navigate the journey from the complicated reality of life and friendship in their 30s to the even more complicated reality of life\u2026","poster":"assets/tv-and-just-like-that-2021-poster.jpg","backdrop":"assets/tv-and-just-like-that-2021-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":116450,"ratings":{"rtCritics":52,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.211,"tmdbVotes":556}},{"id":"tv-anne-with-an-e-2017","title":"Anne with an E","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Family"],"overview":"A coming-of-age story about an outsider who, against all odds and numerous challenges, fights for love and acceptance and for her place in the world. The series centers on a young orphaned girl in the late 1890s,\u2026","poster":"assets/tv-anne-with-an-e-2017-poster.jpg","backdrop":"assets/tv-anne-with-an-e-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":70785,"ratings":{"rtCritics":63,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.7,"tmdbVotes":5019}},{"id":"tv-awkward-2011","title":"Awkward.","year":2011,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"An irreverent look at the conflict, chaos and humor that defines teenage life through the eyes of 15-year-old Jenna Hamilton whose life begins to change when a simple accident becomes an epic misunderstanding and is\u2026","poster":"assets/tv-awkward-2011-poster.jpg","backdrop":"assets/tv-awkward-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":39859,"ratings":{"rtCritics":78,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":427}},{"id":"tv-the-baby-sitters-club-2020","title":"The Baby-Sitters Club","year":2020,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Kids","Drama"],"overview":"In this contemporary take on the beloved book series, five best friends launch a baby-sitting business that's big on fun and adventure.","poster":"assets/tv-the-baby-sitters-club-2020-poster.jpg","backdrop":"assets/tv-the-baby-sitters-club-2020-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100412,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.9,"tmdbVotes":506}},{"id":"tv-ballers-2015","title":"Ballers","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"Looking at the lives of former and current football players, the show follows former superstar Spencer Strasmore as he gets his life on track in retirement while mentoring other current and former players through the\u2026","poster":"assets/tv-ballers-2015-poster.jpg","backdrop":"assets/tv-ballers-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62704,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.809,"tmdbVotes":820}},{"id":"tv-baretta-1975","title":"Baretta","year":1975,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"Baretta is an American detective television series which ran on ABC from 1975 to 1978. The show was a milder version of a successful 1973\u201374 ABC series, Toma, starring Tony Musante as chameleon-like, real-life New\u2026","poster":"assets/tv-baretta-1975-poster.jpg","backdrop":"assets/tv-baretta-1975-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":3216,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":43}},{"id":"tv-based-on-a-true-story-2023","title":"Based on a True Story","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The lives of a realtor, a plumber and a former tennis star unexpectedly collide, exposing America\u2019s obsession with true crime, murder and the slow-close toilet seat.","poster":"assets/tv-based-on-a-true-story-2023-poster.jpg","backdrop":"assets/tv-based-on-a-true-story-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":197373,"ratings":{"rtCritics":79,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":160}},{"id":"tv-the-beast-in-me-2025","title":"The Beast in Me","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery"],"overview":"A famous author is pulled into a twisted mind game with her rich, powerful new neighbor \u2014 who might be a murderer.","poster":"assets/tv-the-beast-in-me-2025-poster.jpg","backdrop":"assets/tv-the-beast-in-me-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":250504,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.072,"tmdbVotes":334}},{"id":"tv-big-city-greens-2018","title":"Big City Greens","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Comedy"],"overview":"The offbeat adventures of 10-year-old Cricket Green, a mischievous and optimistic country boy who moves to the big city with his wildly out of place family \u2013 older sister Tilly, father Bill and Gramma Alice.","poster":"assets/tv-big-city-greens-2018-poster.jpg","backdrop":"assets/tv-big-city-greens-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":80587,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.555,"tmdbVotes":82}},{"id":"tv-the-bold-type-2017","title":"The Bold Type","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Comedy"],"overview":"A glimpse into the outrageous lives of Jane, Kat and Sutton, who are working at the nation's top women's magazine, Scarlet, while navigating their careers, identities and individual voices.","poster":"assets/tv-the-bold-type-2017-poster.jpg","backdrop":"assets/tv-the-bold-type-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":69630,"ratings":{"rtCritics":89,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":204}},{"id":"tv-brassic-2019","title":"Brassic","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama","Crime"],"overview":"A group of working-class friends finding unconventional ways to win at life in suburban northern England. These lads have dealt, scammed, bribed and conned their way through adolescence, but now, their dealing and\u2026","poster":"assets/tv-brassic-2019-poster.jpg","backdrop":"assets/tv-brassic-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":92621,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.336,"tmdbVotes":263}},{"id":"movie-12-years-a-slave-2013","title":"12 Years a Slave","year":2013,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","History"],"overview":"In the pre-Civil War United States, Solomon Northup, a free black man from upstate New York, is abducted and sold into slavery. Facing cruelty as well as unexpected kindnesses Solomon struggles not only to stay\u2026","poster":"assets/movie-12-years-a-slave-2013-poster.jpg","backdrop":"assets/movie-12-years-a-slave-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":76203,"ratings":{"rtCritics":95,"rtCriticsLabel":"Certified Fresh","rtAudience":90,"rtAudienceLabel":"Upright","imdb":8.1,"imdbVotes":796313,"tmdb":7.927,"tmdbVotes":12335}},{"id":"movie-101-dalmatians-ii-patch-s-london-adventure-2003","title":"101 Dalmatians II: Patch's London Adventure","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Family","Animation","Adventure"],"overview":"Being one of 101 takes its toll on Patch, who doesn't feel unique. When he's accidentally left behind on moving day, he meets his idol, Thunderbolt, who enlists him on a publicity campaign.","poster":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-poster.jpg","backdrop":"assets/movie-101-dalmatians-ii-patch-s-london-adventure-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":13654,"ratings":{"rtCritics":67,"rtCriticsLabel":"Fresh","rtAudience":41,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":12343,"tmdb":6.025,"tmdbVotes":1274}},{"id":"movie-absolution-2024","title":"Absolution","year":2024,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Crime","Thriller"],"overview":"An aging ex-boxer gangster working as muscle for a Boston crime boss receives an upsetting diagnosis. Despite a faltering memory, he attempts to rectify the sins of his past and reconnect with his estranged\u2026","poster":"assets/movie-absolution-2024-poster.jpg","backdrop":"assets/movie-absolution-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":974453,"ratings":{"rtCritics":56,"rtCriticsLabel":"Rotten","rtAudience":32,"rtAudienceLabel":"Spilled","imdb":5.2,"imdbVotes":11413,"tmdb":5.906,"tmdbVotes":564}},{"id":"movie-all-the-money-in-the-world-2017","title":"All the Money in the World","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","History","Thriller"],"overview":"The story of the kidnapping of 16-year-old John Paul Getty III and the desperate attempt by his devoted mother to convince his billionaire grandfather Jean Paul Getty to pay the ransom.","poster":"assets/movie-all-the-money-in-the-world-2017-poster.jpg","backdrop":"assets/movie-all-the-money-in-the-world-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":446791,"ratings":{"rtCritics":79,"rtCriticsLabel":"Certified Fresh","rtAudience":66,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":97448,"tmdb":6.493,"tmdbVotes":2493}},{"id":"movie-the-alto-knights-2025","title":"The Alto Knights","year":2025,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"Two of New York's most notorious organized crime bosses, Frank Costello and Vito Genovese, vie for control of the city's streets. Once the best of friends, petty jealousies and a series of betrayals place them on a\u2026","poster":"assets/movie-the-alto-knights-2025-poster.jpg","backdrop":"assets/movie-the-alto-knights-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1013601,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":71,"rtAudienceLabel":"Upright","imdb":5.9,"imdbVotes":23017,"tmdb":6.223,"tmdbVotes":485}},{"id":"movie-arctic-dogs-2019","title":"Arctic Dogs","year":2019,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Adventure","Comedy"],"overview":"Animals band together to save the day when the evil Otto Von Walrus hatches a sinister scheme to accelerate global warming and melt the Arctic Circle.","poster":"assets/movie-arctic-dogs-2019-poster.jpg","backdrop":"assets/movie-arctic-dogs-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":416153,"ratings":{"rtCritics":13,"rtCriticsLabel":"Rotten","rtAudience":60,"rtAudienceLabel":"Upright","imdb":4.9,"imdbVotes":2580,"tmdb":5.9,"tmdbVotes":148}},{"id":"movie-a-bronx-tale-1993","title":"A Bronx Tale","year":1993,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Set in the Bronx during the tumultuous 1960s, an adolescent boy is torn between his honest, working-class father and a violent yet charismatic crime boss. Complicating matters is the youngster's growing attraction -\u2026","poster":"assets/movie-a-bronx-tale-1993-poster.jpg","backdrop":"assets/movie-a-bronx-tale-1993-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1607,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":191930,"tmdb":7.913,"tmdbVotes":3075}},{"id":"movie-by-any-means-2026","title":"By Any Means","year":2026,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Thriller","History","Crime"],"overview":"In 1960s Mississippi, a young Black FBI agent is forced to partner with a notorious mafia hitman to investigate a wave of brutal civil rights murders.","poster":"assets/movie-by-any-means-2026-poster.jpg","backdrop":"assets/movie-by-any-means-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1380417,"ratings":{"rtCritics":49,"rtCriticsLabel":"Rotten","rtAudience":88,"rtAudienceLabel":"Upright","imdb":6.8,"imdbVotes":3534,"tmdb":6.6,"tmdbVotes":92}},{"id":"movie-captain-america-civil-war-2016","title":"Captain America: Civil War","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"Following the events of Age of Ultron, the collective governments of the world pass an act designed to regulate all superhuman activity. This polarizes opinion amongst the Avengers, causing two factions to side with\u2026","poster":"assets/movie-captain-america-civil-war-2016-poster.jpg","backdrop":"assets/movie-captain-america-civil-war-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":271110,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":89,"rtAudienceLabel":"Upright","imdb":7.8,"imdbVotes":954943,"tmdb":7.455,"tmdbVotes":24808}},{"id":"movie-captain-america-the-first-avenger-2011","title":"Captain America: The First Avenger","year":2011,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"During World War II, Steve Rogers is a sickly man from Brooklyn who's transformed into super-soldier Captain America to aid in the war effort. Rogers must stop the Red Skull \u2013 Adolf Hitler's ruthless head of\u2026","poster":"assets/movie-captain-america-the-first-avenger-2011-poster.jpg","backdrop":"assets/movie-captain-america-the-first-avenger-2011-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1771,"ratings":{"rtCritics":80,"rtCriticsLabel":"Certified Fresh","rtAudience":75,"rtAudienceLabel":"Upright","imdb":6.9,"imdbVotes":993453,"tmdb":7.012,"tmdbVotes":23473}},{"id":"movie-captain-america-the-winter-soldier-2014","title":"Captain America: The Winter Soldier","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"After the cataclysmic events in New York with The Avengers, Steve Rogers, aka Captain America is living quietly in Washington, D.C. and trying to adjust to the modern world. But when a S.H.I.E.L.D. colleague comes\u2026","poster":"assets/movie-captain-america-the-winter-soldier-2014-poster.jpg","backdrop":"assets/movie-captain-america-the-winter-soldier-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":100402,"ratings":{"rtCritics":90,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":7.7,"imdbVotes":995514,"tmdb":7.654,"tmdbVotes":20688}},{"id":"tv-12-monkeys-2015","title":"12 Monkeys","year":2015,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Drama"],"overview":"The provocative story of Cole, a time traveler from a decimated future in a high-stakes race against the clock. Utilizing a dangerous and untested method of time travel, he journeys from 2043 to the present day on a\u2026","poster":"assets/tv-12-monkeys-2015-poster.jpg","backdrop":"assets/tv-12-monkeys-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":60948,"ratings":{"rtCritics":88,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.441,"tmdbVotes":1245}},{"id":"tv-accused-2023","title":"Accused","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama"],"overview":"In this crime anthology series, viewers discover how an ordinary person got caught up in an extraordinary situation, ultimately revealing how one wrong turn leads to another, until it\u2019s too late to turn back. Told\u2026","poster":"assets/tv-accused-2023-poster.jpg","backdrop":"assets/tv-accused-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":202097,"ratings":{"rtCritics":61,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.851,"tmdbVotes":87}},{"id":"tv-after-life-2019","title":"After Life","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"Tony had a perfect life. But after his wife Lisa suddenly dies, Tony changes. After contemplating taking his own life, he decides instead to live long enough to punish the world by saying and doing whatever he likes\u2026","poster":"assets/tv-after-life-2019-poster.jpg","backdrop":"assets/tv-after-life-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":79410,"ratings":{"rtCritics":72,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.902,"tmdbVotes":1169}},{"id":"tv-all-creatures-great-and-small-1978","title":"All Creatures Great and Small","year":1978,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Drama"],"overview":"The trials and misadventures of the staff at a country veterinary office in Yorkshire. James Herriot, a young animal surgeon, moves to a small Yorkshire town to begin his first job.","poster":"assets/tv-all-creatures-great-and-small-1978-poster.jpg","backdrop":"assets/tv-all-creatures-great-and-small-1978-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":7406,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":50}},{"id":"tv-american-housewife-2016","title":"American Housewife","year":2016,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family comedy narrated by Katie, a strong-willed mother, raising her flawed family in a wealthy town filled with perfect wives and their perfect offspring.","poster":"assets/tv-american-housewife-2016-poster.jpg","backdrop":"assets/tv-american-housewife-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":67117,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.7,"tmdbVotes":187}},{"id":"tv-atlantis-2013","title":"Atlantis","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy"],"overview":"A fantasy drama set in a world of legendary heroes and mythical creatures. Far from home and desperate for answers, Jason washes up on the shores of an ancient land. A mysterious place; a world of bull leaping, of\u2026","poster":"assets/tv-atlantis-2013-poster.jpg","backdrop":"assets/tv-atlantis-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":47054,"ratings":{"rtCritics":62,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.4,"tmdbVotes":155}},{"id":"tv-beat-bobby-flay-2013","title":"Beat Bobby Flay","year":2013,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Two talented chefs go head-to-head for the chance to Beat Bobby Flay. To get to Bobby the chefs must first face off against each other, creating a spectacular dish with a secret ingredient of Bobby's choice. Judges\u2026","poster":"assets/tv-beat-bobby-flay-2013-poster.jpg","backdrop":"assets/tv-beat-bobby-flay-2013-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":62481,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.5,"tmdbVotes":31}},{"id":"tv-benson-1979","title":"Benson","year":1979,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A butler deals with life at the governor's mansion.","poster":"assets/tv-benson-1979-poster.jpg","backdrop":"assets/tv-benson-1979-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1849,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7,"tmdbVotes":46}},{"id":"tv-black-rabbit-2025","title":"Black Rabbit","year":2025,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"A rising-star restaurateur is forced into New York's criminal underworld when his chaotic brother returns to town with loan sharks on his trail.","poster":"assets/tv-black-rabbit-2025-poster.jpg","backdrop":"assets/tv-black-rabbit-2025-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":249039,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.983,"tmdbVotes":240}},{"id":"tv-black-ish-2014","title":"black-ish","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"A family man struggles to gain a sense of cultural identity while raising his kids in a predominantly white, upper-middle-class neighborhood.","poster":"assets/tv-black-ish-2014-poster.jpg","backdrop":"assets/tv-black-ish-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":61381,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":352}},{"id":"tv-breaking-bad-2008","title":"Breaking Bad","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Crime"],"overview":"Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to\u2026","poster":"assets/tv-breaking-bad-2008-poster.jpg","backdrop":"assets/tv-breaking-bad-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1396,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.959,"tmdbVotes":19030}},{"id":"tv-the-capture-2019","title":"The Capture","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Mystery"],"overview":"When soldier Shaun Emery's conviction for a murder in Afghanistan is overturned due to flawed video evidence, he returns to life as a free man with his young daughter. But when damning CCTV footage from a night out\u2026","poster":"assets/tv-the-capture-2019-poster.jpg","backdrop":"assets/tv-the-capture-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":93166,"ratings":{"rtCritics":97,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.634,"tmdbVotes":361}}],"duplicate":[{"id":"movie-the-amazing-spider-man-2012","title":"The Amazing Spider-Man","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"A teenage Peter Parker grapples with both high school and amazing super-human crises as his alter-ego Spider-Man.","poster":"assets/movie-the-amazing-spider-man-2012-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1930,"ratings":{"rtCritics":71,"rtCriticsLabel":"Certified Fresh","rtAudience":77,"rtAudienceLabel":"Upright","imdb":7,"imdbVotes":791938,"tmdb":6.768,"tmdbVotes":19312}},{"id":"movie-the-amazing-spider-man-2-2014","title":"The Amazing Spider-Man 2","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"For Peter Parker, life is busy. Between taking out the bad guys as Spider-Man and spending time with the person he loves, Gwen Stacy, high school graduation cannot come quickly enough. Peter has not forgotten about\u2026","poster":"assets/movie-the-amazing-spider-man-2-2014-poster.jpg","backdrop":"assets/movie-the-amazing-spider-man-2-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":102382,"ratings":{"rtCritics":51,"rtCriticsLabel":"Rotten","rtAudience":64,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":611981,"tmdb":6.567,"tmdbVotes":14919}},{"id":"movie-the-animatrix-2003","title":"The Animatrix","year":2003,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction"],"overview":"Straight from the creators of the groundbreaking Matrix trilogy, this collection of short animated films from the world's leading anime directors fuses computer graphics and Japanese anime to provide the background\u2026","poster":"assets/movie-the-animatrix-2003-poster.jpg","backdrop":"assets/movie-the-animatrix-2003-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":55931,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":78,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":91929,"tmdb":7.221,"tmdbVotes":1872}},{"id":"movie-ant-man-and-the-wasp-quantumania-2023","title":"Ant-Man and the Wasp: Quantumania","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange\u2026","poster":"assets/movie-ant-man-and-the-wasp-quantumania-2023-poster.jpg","backdrop":"assets/movie-ant-man-and-the-wasp-quantumania-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":640146,"ratings":{"rtCritics":46,"rtCriticsLabel":"Rotten","rtAudience":81,"rtAudienceLabel":"Upright","imdb":6,"imdbVotes":281738,"tmdb":6.197,"tmdbVotes":6236}},{"id":"movie-appleseed-alpha-2014","title":"Appleseed Alpha","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"The Third World War (2099\u20132126) devastated part of the Earth. Former nations are reforming while new powers emerge, and groups of humans struggle to survive in ruined cities. The story follows Deunan Knute and her\u2026","poster":"assets/movie-appleseed-alpha-2014-poster.jpg","backdrop":"assets/movie-appleseed-alpha-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":269650,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":59,"rtAudienceLabel":"Spilled","imdb":6.5,"imdbVotes":9689,"tmdb":6.808,"tmdbVotes":436}},{"id":"movie-aquaman-and-the-lost-kingdom-2023","title":"Aquaman and the Lost Kingdom","year":2023,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Black Manta seeks revenge on Aquaman for his father's death. Wielding the Black Trident's power, he becomes a formidable foe. To defend Atlantis, Arthur (Aquaman) forges an alliance with his imprisoned brother. They\u2026","poster":"assets/movie-aquaman-and-the-lost-kingdom-2023-poster.jpg","backdrop":"assets/movie-aquaman-and-the-lost-kingdom-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":572802,"ratings":{"rtCritics":33,"rtCriticsLabel":"Rotten","rtAudience":79,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":122422,"tmdb":6.48,"tmdbVotes":3748}},{"id":"movie-the-avengers-2012","title":"The Avengers","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Adventure"],"overview":"When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back\u2026","poster":"assets/movie-the-avengers-2012-poster.jpg","backdrop":"assets/movie-the-avengers-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":24428,"ratings":{"rtCritics":91,"rtCriticsLabel":"Certified Fresh","rtAudience":91,"rtAudienceLabel":"Upright","imdb":8,"imdbVotes":1590693,"tmdb":8.084,"tmdbVotes":40262}},{"id":"movie-avengers-confidential-black-widow-punisher-2014","title":"Avengers Confidential: Black Widow & Punisher","year":2014,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Science Fiction","Action"],"overview":"When the Punisher takes out a black-market weapons dealer, he stumbles upon a far-reaching terrorist plot devised by a group known as Leviathan.","poster":"assets/movie-avengers-confidential-black-widow-punisher-2014-poster.jpg","backdrop":"assets/movie-avengers-confidential-black-widow-punisher-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":257346,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":55,"rtAudienceLabel":"Spilled","imdb":5.7,"imdbVotes":5166,"tmdb":6.368,"tmdbVotes":303}},{"id":"movie-avengers-age-of-ultron-2015","title":"Avengers: Age of Ultron","year":2015,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"When Tony Stark tries to jumpstart a dormant peacekeeping program, things go awry and Earth\u2019s Mightiest Heroes are put to the ultimate test as the fate of the planet hangs in the balance. As the villainous Ultron\u2026","poster":"assets/movie-avengers-age-of-ultron-2015-poster.jpg","backdrop":"assets/movie-avengers-age-of-ultron-2015-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":99861,"ratings":{"rtCritics":75,"rtCriticsLabel":"Certified Fresh","rtAudience":82,"rtAudienceLabel":"Upright","imdb":7.3,"imdbVotes":1025876,"tmdb":7.278,"tmdbVotes":25289}},{"id":"movie-avengers-infinity-war-2018","title":"Avengers: Infinity War","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Adventure","Action","Science Fiction"],"overview":"As the Avengers and their allies have continued to protect the world from threats too large for any one hero to handle, a new danger has emerged from the cosmic shadows: Thanos. A despot of intergalactic infamy, his\u2026","poster":"assets/movie-avengers-infinity-war-2018-poster.jpg","backdrop":"assets/movie-avengers-infinity-war-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":299536,"ratings":{"rtCritics":85,"rtCriticsLabel":"Certified Fresh","rtAudience":92,"rtAudienceLabel":"Upright","imdb":8.4,"imdbVotes":1421954,"tmdb":8.246,"tmdbVotes":33331}},{"id":"movie-batman-ninja-2018","title":"Batman Ninja","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action","Science Fiction"],"overview":"Batman, along with many of his allies and adversaries, finds himself transported to feudal Japan by Gorilla Grodd's time displacement machine.","poster":"assets/movie-batman-ninja-2018-poster.jpg","backdrop":"assets/movie-batman-ninja-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":485942,"ratings":{"rtCritics":84,"rtCriticsLabel":"Fresh","rtAudience":60,"rtAudienceLabel":"Upright","imdb":5.6,"imdbVotes":24579,"tmdb":5.9,"tmdbVotes":1106}},{"id":"movie-batman-v-superman-dawn-of-justice-2016","title":"Batman v Superman: Dawn of Justice","year":2016,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Fantasy"],"overview":"Fearing the actions of a god-like Super Hero left unchecked, Gotham City\u2019s own formidable, forceful vigilante takes on Metropolis\u2019s most revered, modern-day savior, while the world wrestles with what sort of hero it\u2026","poster":"assets/movie-batman-v-superman-dawn-of-justice-2016-poster.jpg","backdrop":"assets/movie-batman-v-superman-dawn-of-justice-2016-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":209112,"ratings":{"rtCritics":28,"rtCriticsLabel":"Rotten","rtAudience":63,"rtAudienceLabel":"Upright","imdb":6.4,"imdbVotes":801382,"tmdb":6.014,"tmdbVotes":19527}},{"id":"movie-batman-the-dark-knight-returns-part-1-2012","title":"Batman: The Dark Knight Returns, Part 1","year":2012,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Science Fiction","Action","Animation"],"overview":"Batman has not been seen for ten years. A new breed of criminal ravages Gotham City, forcing 55-year-old Bruce Wayne back into the cape and cowl. But, does he still have what it takes to fight crime in a new era?","poster":"assets/movie-batman-the-dark-knight-returns-part-1-2012-poster.jpg","backdrop":"assets/movie-batman-the-dark-knight-returns-part-1-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":123025,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":7.9,"imdbVotes":68943,"tmdb":7.742,"tmdbVotes":1766}},{"id":"movie-black-adam-2022","title":"Black Adam","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods\u2014and imprisoned just as quickly\u2014Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the\u2026","poster":"assets/movie-black-adam-2022-poster.jpg","backdrop":"assets/movie-black-adam-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":436270,"ratings":{"rtCritics":39,"rtCriticsLabel":"Rotten","rtAudience":87,"rtAudienceLabel":"Upright","imdb":6.1,"imdbVotes":309624,"tmdb":6.747,"tmdbVotes":7370}},{"id":"movie-black-panther-2018","title":"Black Panther","year":2018,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"King T'Challa returns home to the reclusive, technologically advanced African nation of Wakanda to serve as his country's new leader. However, T'Challa soon finds that he is challenged for the throne by factions\u2026","poster":"assets/movie-black-panther-2018-poster.jpg","backdrop":"assets/movie-black-panther-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284054,"ratings":{"rtCritics":96,"rtCriticsLabel":"Certified Fresh","rtAudience":null,"rtAudienceLabel":"Spilled","imdb":7.3,"imdbVotes":931120,"tmdb":7.36,"tmdbVotes":24190}},{"id":"movie-black-panther-wakanda-forever-2022","title":"Black Panther: Wakanda Forever","year":2022,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Adventure","Science Fiction"],"overview":"Queen Ramonda, Shuri, M\u2019Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T\u2019Challa\u2019s death. As the Wakandans strive to embrace their next chapter, the\u2026","poster":"assets/movie-black-panther-wakanda-forever-2022-poster.jpg","backdrop":"assets/movie-black-panther-wakanda-forever-2022-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":505642,"ratings":{"rtCritics":84,"rtCriticsLabel":"Certified Fresh","rtAudience":93,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":405088,"tmdb":6.984,"tmdbVotes":7920}},{"id":"movie-blame-2017","title":"BLAME!","year":2017,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Drama","Science Fiction"],"overview":"In the distant technological future, civilization has reached its ultimate Net-based form. An \"infection\" in the past caused the automated systems to spiral out of order, resulting in a multi-leveled city structure\u2026","poster":"assets/movie-blame-2017-poster.jpg","backdrop":"assets/movie-blame-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":409421,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":61,"rtAudienceLabel":"Upright","imdb":6.6,"imdbVotes":10258,"tmdb":6.523,"tmdbVotes":370}},{"id":"movie-bleach-the-movie-fade-to-black-2008","title":"Bleach the Movie: Fade to Black","year":2008,"type":"movie","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action","Animation","Fantasy"],"overview":"A strange disturbance in the World of the Living sends Ichigo and Kon to the Soul Society, where the Seireitei has been devastated by an explosion of reishi and Rukia is missing. Stranger still, none of the Soul\u2026","poster":"assets/movie-bleach-the-movie-fade-to-black-2008-poster.jpg","backdrop":"assets/movie-bleach-the-movie-fade-to-black-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":17165,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":67,"rtAudienceLabel":"Upright","imdb":7.1,"imdbVotes":3693,"tmdb":7.1,"tmdbVotes":206}},{"id":"tv-90-day-fiance-before-the-90-days-2017","title":"90 Day Fianc\u00e9: Before the 90 Days","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Follow couples who have an existing relationship online, but haven't met in person. We'll experience their journey as they travel to the other's foreign country for the first time in an attempt to establish an\u2026","poster":"assets/tv-90-day-fiance-before-the-90-days-2017-poster.jpg","backdrop":"assets/tv-90-day-fiance-before-the-90-days-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":73319,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":45}},{"id":"tv-90-day-fiance-the-other-way-2019","title":"90 Day Fianc\u00e9: The Other Way","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Reality"],"overview":"Americans move to a foreign country all in the name of love. Will uprooting their lives in the United States pay off, or will they be packing up and moving back to America brokenhearted?","poster":"assets/tv-90-day-fiance-the-other-way-2019-poster.jpg","backdrop":"assets/tv-90-day-fiance-the-other-way-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":90046,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":34}},{"id":"tv-the-100-2014","title":"The 100","year":2014,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"100 years in the future, when the Earth has been abandoned due to radioactivity, the last surviving humans live on an ark orbiting the planet \u2014 but the ark won't last forever. So the repressive regime picks 100\u2026","poster":"assets/tv-the-100-2014-poster.jpg","backdrop":"assets/tv-the-100-2014-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":48866,"ratings":{"rtCritics":93,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.878,"tmdbVotes":8855}},{"id":"tv-the-abbott-and-costello-show-1952","title":"The Abbott and Costello Show","year":1952,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy"],"overview":"Bud and Lou are unemployed actors living in Mr. Fields\u2019 boarding house. Lou\u2019s girlfriend Hillary lives across the hall. Many situations arise leading to slapstick and puns.","poster":"assets/tv-the-abbott-and-costello-show-1952-poster.jpg","backdrop":"assets/tv-the-abbott-and-costello-show-1952-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":11821,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.2,"tmdbVotes":22}},{"id":"tv-accel-world-2012","title":"Accel World","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Drama"],"overview":"A short, overweight student uses the Neuro Link, an evolution of the Nerve Gear, to escape himself from the reality. But his skills in the virtual world brings him to the attention of Kuroyukihime, a popular student\u2026","poster":"assets/tv-accel-world-2012-poster.jpg","backdrop":"assets/tv-accel-world-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"center_crop","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":43167,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.429,"tmdbVotes":163}},{"id":"tv-ahsoka-2023","title":"Ahsoka","year":2023,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure","Drama"],"overview":"Former Jedi Knight Ahsoka Tano investigates an emerging threat to a vulnerable galaxy.","poster":"assets/tv-ahsoka-2023-poster.jpg","backdrop":"assets/tv-ahsoka-2023-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":114461,"ratings":{"rtCritics":85,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.4,"tmdbVotes":1351}},{"id":"tv-american-dad-2005","title":"American Dad!","year":2005,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The series focuses on an eccentric motley crew that is the Smith family and their three housemates: Father, husband, and breadwinner Stan Smith; his better half housewife, Francine Smith; their college-aged daughter,\u2026","poster":"assets/tv-american-dad-2005-poster.jpg","backdrop":"assets/tv-american-dad-2005-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1433,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.022,"tmdbVotes":2481}},{"id":"tv-american-gods-2017","title":"American Gods","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Drama","Mystery","Sci-Fi & Fantasy"],"overview":"An ex-con becomes the traveling partner of a conman who turns out to be one of the older gods trying to recruit troops to battle the upstart deities. Based on Neil Gaiman's fantasy novel.","poster":"assets/tv-american-gods-2017-poster.jpg","backdrop":"assets/tv-american-gods-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":46639,"ratings":{"rtCritics":75,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.1,"tmdbVotes":1852}},{"id":"tv-andromeda-2000","title":"Andromeda","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Drama","Sci-Fi & Fantasy"],"overview":"Captain Dylan Hunt and his crew quest to restore a government that once presided over an extended peace and prosperity.","poster":"assets/tv-andromeda-2000-poster.jpg","backdrop":"assets/tv-andromeda-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":2985,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.068,"tmdbVotes":315}},{"id":"tv-aqua-teen-hunger-force-2000","title":"Aqua Teen Hunger Force","year":2000,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Comedy"],"overview":"The surreal adventures of three anthropomorphic fast food items: Master Shake, Frylock and Meatwad, and their human nextdoor neighbor, Carl Brutananadilewski.","poster":"assets/tv-aqua-teen-hunger-force-2000-poster.jpg","backdrop":"assets/tv-aqua-teen-hunger-force-2000-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":251,"ratings":{"rtCritics":null,"rtCriticsLabel":"Rotten","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.5,"tmdbVotes":272}},{"id":"tv-arifureta-from-commonplace-to-world-s-strongest-2019","title":"Arifureta: From Commonplace to World's Strongest","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"Hajime Nagumo and his high school class are suddenly summoned to a fantastical land as heroes. But while most of his classmates have powerful stats and abilities, Hajime does not. Underappreciated and unprepared, he\u2026","poster":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-poster.jpg","backdrop":"assets/tv-arifureta-from-commonplace-to-world-s-strongest-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":86034,"ratings":{"rtCritics":null,"rtCriticsLabel":null,"rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.382,"tmdbVotes":662}},{"id":"tv-arrow-2012","title":"Arrow","year":2012,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Crime","Drama","Action & Adventure"],"overview":"Spoiled billionaire playboy Oliver Queen is missing and presumed dead when his yacht is lost at sea. He returns five years later a changed man, determined to clean up the city as a hooded vigilante armed with a bow.","poster":"assets/tv-arrow-2012-poster.jpg","backdrop":"assets/tv-arrow-2012-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":1412,"ratings":{"rtCritics":86,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.834,"tmdbVotes":6537}},{"id":"tv-avatar-the-last-airbender-2024","title":"Avatar: The Last Airbender","year":2024,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"A young boy known as the Avatar must master the four elemental powers to save a world at war \u2014 and fight a ruthless enemy bent on stopping him.","poster":"assets/tv-avatar-the-last-airbender-2024-poster.jpg","backdrop":"assets/tv-avatar-the-last-airbender-2024-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":82452,"ratings":{"rtCritics":66,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.8,"tmdbVotes":1406}},{"id":"tv-avatar-seven-havens-2026","title":"Avatar: Seven Havens","year":2026,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"In a world shattered by a devastating cataclysm, a young Earthbender discovers she's the new Avatar after Korra. But in this dangerous era, that title marks her as humanity's destroyer, not its savior. Hunted by both\u2026","poster":"assets/tv-avatar-seven-havens-2026-poster.jpg","backdrop":"assets/tv-avatar-seven-havens-2026-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":284833,"ratings":{"rtCritics":100,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":8.9,"tmdbVotes":183}},{"id":"tv-batman-the-brave-and-the-bold-2008","title":"Batman: The Brave and the Bold","year":2008,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Animation","Action & Adventure","Sci-Fi & Fantasy"],"overview":"The Caped Crusader is teamed up with Blue Beetle, Green Arrow, Aquaman and countless others in his quest to uphold justice.","poster":"assets/tv-batman-the-brave-and-the-bold-2008-poster.jpg","backdrop":"assets/tv-batman-the-brave-and-the-bold-2008-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":15804,"ratings":{"rtCritics":96,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.6,"tmdbVotes":295}},{"id":"tv-batwoman-2019","title":"Batwoman","year":2019,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Sci-Fi & Fantasy","Action & Adventure"],"overview":"Kate Kane, armed with a passion for social justice and a flair for speaking her mind, soars onto the streets of Gotham as Batwoman, an out lesbian and highly trained street fighter primed to snuff out the failing\u2026","poster":"assets/tv-batwoman-2019-poster.jpg","backdrop":"assets/tv-batwoman-2019-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":89247,"ratings":{"rtCritics":83,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.8,"tmdbVotes":1619}},{"id":"tv-big-mouth-2017","title":"Big Mouth","year":2017,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Comedy","Animation"],"overview":"Teenage friends find their lives upended by the wonders and horrors of puberty in this edgy comedy from real-life pals Nick Kroll and Andrew Goldberg.","poster":"assets/tv-big-mouth-2017-poster.jpg","backdrop":"assets/tv-big-mouth-2017-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":74204,"ratings":{"rtCritics":95,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":7.591,"tmdbVotes":1274}},{"id":"tv-black-lightning-2018","title":"Black Lightning","year":2018,"type":"tv","runtimeSeconds":null,"seasonCount":null,"communityRating":null,"criticRating":null,"genres":["Action & Adventure","Sci-Fi & Fantasy","Drama"],"overview":"Jefferson Pierce is a man wrestling with a secret. As the father of two daughters and principal of a charter high school that also serves as a safe haven for young people in a New Orleans neighborhood overrun by gang\u2026","poster":"assets/tv-black-lightning-2018-poster.jpg","backdrop":"assets/tv-black-lightning-2018-backdrop.jpg","played":null,"playCount":null,"resumeSeconds":null,"progress":null,"addedAt":null,"lastPlayedAt":null,"availability":"not_here","availabilityEvidence":"retained_profile_membership_and_native_request_ledger","source":"materialized_personal_recommendation","rank":null,"watchStateKnown":false,"artwork":{"posterKind":"poster","backdropKind":"poster_crop"},"availabilityIsSnapshot":true,"tmdbId":71663,"ratings":{"rtCritics":92,"rtCriticsLabel":"Fresh","rtAudience":null,"rtAudienceLabel":null,"imdb":null,"imdbVotes":null,"tmdb":6.907,"tmdbVotes":991}}]}} diff --git a/wolfskin-design/src/test/resources/wolfskin-requests.json b/wolfskin-design/src/test/resources/wolfskin-requests.json deleted file mode 100644 index 507ebd65..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-requests.json +++ /dev/null @@ -1,217 +0,0 @@ -{ - "source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order", - "items": [ - { - "id": "x1", - "mediaType": "tv", - "tmdbId": 11821, - "title": "The Abbott and Costello Show", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Downloading", - "poster": "tv-the-abbott-and-costello-show-1952-poster.jpg", - "ratings": [ - { - "mark": "tmdb", - "score": 7.2, - "percentage": false - } - ] - }, - { - "id": "x2", - "mediaType": "movie", - "tmdbId": 13483, - "title": "Awake", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "justin", - "actorName": "Justin", - "stage": "AlmostReady", - "poster": "movie-awake-2007-poster.jpg", - "ratings": [ - { - "mark": "rotten", - "score": 23, - "percentage": true - }, - { - "mark": "spilled", - "score": 55, - "percentage": true - }, - { - "mark": "imdb", - "score": 6.5, - "percentage": false - } - ] - }, - { - "id": "x3", - "mediaType": "tv", - "tmdbId": 70785, - "title": "Anne with an E", - "requesterKey": "shared", - "requesterName": "Shared", - "actorKey": "mindel", - "actorName": "Mindel", - "stage": "Searching", - "poster": "tv-anne-with-an-e-2017-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 63, - "percentage": true - } - ] - }, - { - "id": "x4", - "mediaType": "movie", - "tmdbId": 99861, - "title": "Avengers: Age of Ultron", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Waiting", - "poster": "movie-avengers-age-of-ultron-2015-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 75, - "percentage": true - }, - { - "mark": "upright", - "score": 82, - "percentage": true - }, - { - "mark": "imdb", - "score": 7.3, - "percentage": false - } - ] - }, - { - "id": "x5", - "mediaType": "movie", - "tmdbId": 1033148, - "title": "Die My Love", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "mindel", - "actorName": "Mindel", - "stage": "Failed", - "poster": "movie-die-my-love-2025-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 74, - "percentage": true - }, - { - "mark": "spilled", - "score": 45, - "percentage": true - }, - { - "mark": "imdb", - "score": 6, - "percentage": false - } - ] - }, - { - "id": "x6", - "mediaType": "movie", - "tmdbId": 416153, - "title": "Arctic Dogs", - "requesterKey": "shared", - "requesterName": "Shared", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Ready", - "poster": "movie-arctic-dogs-2019-poster.jpg", - "ratings": [ - { - "mark": "rotten", - "score": 13, - "percentage": true - }, - { - "mark": "upright", - "score": 60, - "percentage": true - }, - { - "mark": "imdb", - "score": 4.9, - "percentage": false - } - ] - }, - { - "id": "x7", - "mediaType": "movie", - "tmdbId": 76203, - "title": "12 Years a Slave", - "requesterKey": "justin", - "requesterName": "Justin", - "actorKey": "shared", - "actorName": "Shared", - "stage": "Waiting", - "poster": "movie-12-years-a-slave-2013-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 95, - "percentage": true - }, - { - "mark": "upright", - "score": 90, - "percentage": true - }, - { - "mark": "imdb", - "score": 8.1, - "percentage": false - } - ] - }, - { - "id": "x8", - "mediaType": "movie", - "tmdbId": 14069, - "title": "The Girl Who Leapt Through Time", - "requesterKey": "mindel", - "requesterName": "Mindel", - "actorKey": "justin", - "actorName": "Justin", - "stage": "Waiting", - "poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg", - "ratings": [ - { - "mark": "fresh", - "score": 87, - "percentage": true - }, - { - "mark": "upright", - "score": 90, - "percentage": true - }, - { - "mark": "imdb", - "score": 7.6, - "percentage": false - } - ] - } - ] -} diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg deleted file mode 100644 index ff1f55e6..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-12-years-a-slave-2013-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg deleted file mode 100644 index 07552443..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-arctic-dogs-2019-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg deleted file mode 100644 index 300840ff..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-avengers-age-of-ultron-2015-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg deleted file mode 100644 index 32595897..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-awake-2007-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg deleted file mode 100644 index f6eca818..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-die-my-love-2025-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg deleted file mode 100644 index ded6cd2a..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/movie-the-girl-who-leapt-through-time-2006-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg deleted file mode 100644 index 55d443b4..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/tv-anne-with-an-e-2017-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg b/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg deleted file mode 100644 index 7d6768ea..00000000 Binary files a/wolfskin-design/src/test/resources/wolfskin-requests/tv-the-abbott-and-costello-show-1952-poster.jpg and /dev/null differ diff --git a/wolfskin-design/src/test/resources/wolfskin-shell.json b/wolfskin-design/src/test/resources/wolfskin-shell.json deleted file mode 100644 index d99323a9..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-shell.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", - "profiles": [ - { - "key": "justin", - "name": "Justin", - "initial": "J", - "color": "#62c4ff", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Anime": { - "kind": "library", - "collection": "anime" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - }, - { - "key": "mindel", - "name": "Mindel", - "initial": "M", - "color": "#ff9d72", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - }, - { - "key": "shared", - "name": "Shared", - "initial": "S", - "color": "#9be38f", - "destinations": { - "Home": { - "kind": "home" - }, - "Movies": { - "kind": "library", - "collection": "movies" - }, - "Shows": { - "kind": "library", - "collection": "shows" - }, - "Discover": { - "kind": "discover", - "collection": "discover" - } - } - } - ] -} diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json deleted file mode 100644 index 5138e020..00000000 --- a/wolfskin-design/title-tokens.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "title": { - "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, - "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, - "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, - "factsGap": {"dp": 11, "px": 22, "source": "index.html:104"}, - "factsBottom": {"dp": 7, "px": 14, "source": "index.html:104"}, - "factsHeight": {"dp": 19, "px": 38, "source": "index.html:104 .h-meta minimum height"}, - "titleBottom": {"dp": 8, "px": 16, "source": "index.html:103"}, - "iconGap": {"dp": 7, "px": 14, "source": "index.html:108"}, - "iconSize": {"dp": 16, "px": 32, "source": "index.html:275"}, - "captionTop": {"dp": 8, "px": 16, "source": "index.html:281"}, - "progressWidth": {"dp": 160, "px": 320, "source": "index.html:218"}, - "progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"}, - "progressGap": {"dp": 9, "px": 18, "source": "index.html:217"}, - "progressLift": {"dp": -7, "px": -14, "source": "index.html:217"}, - "castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"}, - "castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"}, - "castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"}, - "personTop": {"dp": 85, "px": 170, "source": "index.html:293"}, - "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, - "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, - "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, - "captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"}, - "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, - "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, - "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, - "sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"}, - "sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"}, - "sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"}, - "sheetButtonTop": {"dp": 6, "px": 12, "source": "index.html:241 .sheet .btn margin-top"}, - "sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"}, - "versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"}, - "trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"}, - "trackGap": {"dp": 6, "px": 12, "source": "index.html:1941"}, - "radioSize": {"dp": 20, "px": 40, "source": "index.html:234"}, - "radioDot": {"dp": 8, "px": 16, "source": "index.html:236"}, - "radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"}, - "infoGap": {"dp": 7, "px": 14, "source": "index.html:1948"}, - "infoLabelWidth": {"dp": 100, "px": 200, "source": "index.html:318"}, - "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, - "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, - "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, - "gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"}, - "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, - "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, - "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, - "initialSize": {"$type": "typography", "sp": 32, "px": 64, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:288"}, - "personInitialSize": {"$type": "typography", "sp": 48, "px": 96, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:1317"}, - "fallbackHue": {"$value": 260, "source": "index.html:130"}, - "skeletonKickerWidth": {"dp": 210, "px": 420, "source": "index.html:1322"}, - "skeletonKickerHeight": {"dp": 17, "px": 34, "source": "index.html:1322"}, - "skeletonTitleGap": {"dp": 13, "px": 26, "source": "index.html:1322"}, - "skeletonFactsGap": {"dp": 12, "px": 24, "source": "index.html:1322"}, - "skeletonOverviewGap": {"dp": 15, "px": 30, "source": "index.html:1322"}, - "skeletonTitleWidth": {"dp": 380, "px": 760, "source": "index.html:1322"}, - "skeletonTitleHeight": {"dp": 46, "px": 92, "source": "index.html:1322"}, - "skeletonFactsWidth": {"dp": 280, "px": 560, "source": "index.html:1322"}, - "skeletonOverviewWidth": {"dp": 490, "px": 980, "source": "index.html:1322"}, - "skeletonOverviewHeight": {"dp": 55, "px": 110, "source": "index.html:1322"}, - "skeletonPlayWidth": {"dp": 110, "px": 220, "source": "index.html:1322"}, - "dLine": {"$type": "typography", "sp": 13, "px": 26, "weight": 400, "$value": {"fontWeight": 400}, "source": "index.html:214"}, - "iconCaption": {"$type": "typography", "sp": 13, "px": 26, "weight": 400, "$value": {"fontWeight": 400}, "source": "index.html:281"}, - "lineIcon": {"dp": 14, "px": 28, "source": "index.html:215"}, - "chipHeight": {"dp": 34, "px": 68, "source": "index.html:185"}, - "chipPadding": {"dp": 14, "px": 28, "source": "index.html:185"}, - "chipGap": {"dp": 8, "px": 16, "source": "index.html:184"}, - "chipTop": {"dp": 8, "px": 16, "source": "index.html:184"}, - "chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"}, - "heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"}, - "heroSheetTop": {"dp": 75, "px": 150, "source": "index.html:97"}, - "gridHeadingTop": {"dp": 12, "px": 24, "source": "index.html:1312"}, - "castName": {"$type": "typography", "sp": 13, "px": 26, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:290"}, - "receiptSide": {"dp": 15, "px": 30, "source": "index.html:243"}, - "receiptTop": {"dp": 14, "px": 28, "source": "index.html:243"}, - "receiptGap": {"dp": 5, "px": 10, "source": "index.html:243"}, - "receiptIcon": {"dp": 18, "px": 36, "source": "index.html:245"}, - "receiptTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:244"}, - "toastMaxWidth": {"dp": 380, "px": 760, "source": "index.html:270"}, - "toastSide": {"dp": 14, "px": 28, "source": "index.html:270"}, - "toastTop": {"dp": 11, "px": 22, "source": "index.html:270"}, - "heroAmbientBackground": {"$type": "gradient", "$value": "radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg))", "source": "index.html:94"}, - "dimScrollPx": {"$value": 1400, "source": "index.html:1541"}, - "dimMaximum": {"$value": 0.7, "source": "index.html:1541"}, - "errorTop": {"dp": 85, "px": 170, "source": "index.html:1457,194"}, - "errorSide": {"dp": 20, "px": 40, "source": "index.html:194"}, - "errorVertical": {"dp": 17, "px": 34, "source": "index.html:194"}, - "errorHeadingBottom": {"dp": 4, "px": 8, "source": "index.html:195"}, - "errorBodyBottom": {"dp": 12, "px": 24, "source": "index.html:198"}, - "errorTitle": {"$type": "typography", "sp": 18, "px": 36, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:195"}, - "fallbackGlyph": {"dp": 22, "px": 44, "source": "index.html:131"}, - "fallbackInset": {"dp": 11, "px": 22, "source": "index.html:130-131"}, - "tileSheetInset": {"dp": 9, "px": 18, "source": "index.html:128"}, - "tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"}, - "tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"}, - "tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"}, - "tileAmbientInset": {"dp": 15, "px": 30, "source": "index.html:129"}, - "tileShadowRadius": {"dp": 15, "px": 30, "source": "index.html:128"}, - "tileShadowY": {"dp": 5, "px": 10, "source": "index.html:128"}, - "tileShadowOpacity": {"$value": 0.5, "source": "index.html:128"}, - "tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"}, - "cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"}, - "personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"}, - "initialsColor": {"$type": "color", "$value": "#ffffffd1", "source": "index.html:288"}, - "fallbackGlyphColor": {"$type": "color", "$value": "#ffffff73", "source": "index.html:131"}, - "fallbackTitle": {"$type": "typography", "sp": 14, "px": 28, "weight": 700, "lineHeight": 1.15, "$value": {"fontWeight": 700}, "source": "index.html:132"}, - "tileOverlayTop": {"dp": 27, "px": 54, "source": "index.html:135"}, - "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, - "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, - "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, - "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, - "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, - "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, - "ambientScale": {"$value": 1.1, "source": "index.html:96"}, - "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, - "posterShadowY": {"dp": 15, "px": 30, "source": "index.html:97"}, - "posterShadowOpacity": {"$value": 0.6, "source": "index.html:97"}, - "sheetShadowRadius": {"dp": 40, "px": 80, "source": "index.html:227"}, - "sheetShadowX": {"dp": -15, "px": -30, "source": "index.html:227"}, - "sheetShadowOpacity": {"$value": 0.5, "source": "index.html:227"} - }, - "savedIndicator": { - "height": {"dp": 26, "px": 52, "source": "index.html:87"}, - "side": {"dp": 10, "px": 20, "source": "index.html:87"}, - "gap": {"dp": 5, "px": 10, "source": "index.html:87"}, - "icon": {"dp": 14, "px": 28, "source": "index.html:88"}, - "border": {"dp": 1, "px": 2, "source": "index.html:87"}, - "borderOpacity": {"$value": 0.5, "source": "index.html:87"} - } -} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css deleted file mode 100644 index 9c7f17f1..00000000 --- a/wolfskin-design/tokens.css +++ /dev/null @@ -1,493 +0,0 @@ -/* Generated from the locked TV token sources. */ -:root { - --color-bg: #07070a; - --color-surface: #13131a; - --color-surface2: #1b1b25; - --color-surface3: #252532; - --color-fg: #f4f4f6; - --color-muted: #f4f4f6bd; - --color-dim: #f4f4f68f; - --color-border: #ffffff1a; - --color-accent: #cc9fff; - --color-accentSoft: #2c1a3f; - --color-accentText: #e4cbff; - --color-ok: #89da9b; - --color-warn: #f4c26a; - --color-fail: #f97770; - --color-onFocus: #0b0b10; - --color-buttonSecondary: #ffffff24; - --color-buttonSecondaryFocused: #ffffff42; - --color-titleButtonSecondary: #2c2c38c7; - --color-titleButtonSecondaryFocused: #464654e6; - --color-chipRest: #ffffff1a; - --color-navBlobRest: #ffffff1a; - --color-listRowRest: #ffffff0d; - --color-sheetScrim: #0000009e; - --color-badgeRest: #07070abf; - --color-badgeWatched: #f4f4f6d9; - --color-progressTrack: #ffffff38; - --color-railEmpty: #f4f4f638; - --personColor-justin: #62c4ff; - --personColor-mindel: #ff9d72; - --personColor-shared: #9be38f; - --gradient-heroScrim: linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%); - --gradient-detailScrim: linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%); - --gradient-navBand: linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0)); - --gradient-navBandSolid: linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%); - --gradient-ratingPlate: linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%); - --gradient-tileOverlay: linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0)); - --gradient-fallbackArt: linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%)); - --gradient-entryTile: linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%)); - --gradient-initialsCircle: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); - --type-displayDetail: 88.0px; - --type-displayHero: 84.0px; - --type-screenTitle: 64.0px; - --type-sectionTitle: 56.0px; - --type-sheetTitle: 44.0px; - --type-wordmark: 36.0px; - --type-stateTitle: 36.0px; - --type-rowHeading: 34.0px; - --type-pickerName: 34.0px; - --type-nav: 30.0px; - --type-listTitle: 30.0px; - --type-body: 28.0px; - --type-button: 28.0px; - --type-label: 26.0px; - --type-caption: 24.0px; - --type-ratingPlate: 28.0px; - --radius-card: 16.0px; - --radius-button: 38.0px; - --radius-chip: 34.0px; - --radius-navItem: 32.0px; - --radius-listRow: 24.0px; - --radius-panel: 22.0px; - --radius-menu: 26.0px; - --radius-heroSheet: 18.0px; - --radius-cardSheet: 10.0px; - --radius-thumb: 12.0px; - --radius-pill: 22.0px; - --radius-progress: 4.0px; - --layout-safeSide: 96.0px; - --layout-contentWidth: 1728.0px; - --layout-safeTopPicker: 54.0px; - --layout-navBand: 150.0px; - --layout-navPaddingTop: 44.0px; - --layout-navItemHeight: 64.0px; - --layout-navItemPaddingX: 26.0px; - --layout-navItemGap: 12.0px; - --layout-wordmarkGap: 40.0px; - --layout-navUnderline: 4.0px; - --layout-navBlobAvatarPad: 8.0px; - --layout-navSolidAfter: 40.0px; - --layout-pinnedLine: 168.0px; - --layout-windowAhead: 700.0px; - --layout-rowHeadingBand: 84.0px; - --layout-rowGapAfter: 68.0px; - --layout-gridRowGap: 44.0px; - --layout-row_hero: 820.0px; - --layout-row_phero: 740.0px; - --layout-row_section: 150.0px; - --layout-row_chips: 104.0px; - --layout-row_note: 84.0px; - --layout-row_state: 300.0px; - --layout-row_lhead: 236.0px; - --layout-row_dhead: 900.0px; - --layout-row_phead: 470.0px; - --layout-row_gh: 84.0px; - --layout-row_endcta: 200.0px; - --layout-row_srow: 124.0px; - --layout-heroCopyLeft: 96.0px; - --layout-heroCopyBottom: 110.0px; - --layout-heroCopyWidth: 1000.0px; - --layout-heroOverviewMax: 960.0px; - --layout-detailCopyTop: 200.0px; - --layout-detailCopyWidth: 1060.0px; - --layout-buttonHeight: 76.0px; - --layout-buttonPaddingX: 38.0px; - --layout-buttonGap: 20.0px; - --layout-buttonIcon: 30.0px; - --layout-iconButton: 76.0px; - --layout-iconCaptionLine: 38.0px; - --layout-sheetWidth: 800.0px; - --layout-sheetRow: 100.0px; - --layout-stateWidth: 1100.0px; - --layout-captionOffset: 38.0px; - --layout-badge: 48.0px; - --layout-badgeInset: 14.0px; - --layout-progressBar: 8.0px; - --layout-pickerAvatar: 220.0px; - --layout-pickerGap: 90.0px; - --layout-pickerStackGap: 70.0px; - --layout-gear: 56.0px; - --layout-navAvatar: 64.0px; - --layout-castCircle: 200.0px; - --layout-personPortrait: 240.0px; - --layout-row_chipsGrid: 136.0px; - --motion-fast: 140ms; - --motion-base: 160ms; - --motion-scroll: 180ms; - --motion-ratingDwell: 350ms; - --motion-toast: 3200ms; - --requests-sheetPaddingY: 80.0px; - --requests-sheetPaddingX: 64.0px; - --requests-sheetGap: 18.0px; - --requests-sheetTitleLineHeight: 56.0px; - --requests-sheetTitleBaseline: 44.0px; - --requests-titleLineHeight: 39.0px; - --requests-titleBaseline: 31.0px; - --requests-factsLineHeight: 31.2px; - --requests-factsBaseline: 24.0px; - --requests-stageLineHeight: 39.6px; - --requests-stageLabelLineHeight: 33.8px; - --requests-stageBaseline: 24.0px; - --requests-emptyMargin: 20.0px; - --requests-rowPaddingY: 14.0px; - --requests-rowPaddingX: 18.0px; - --requests-rowGap: 24.0px; - --requests-listGap: 6.0px; - --requests-stageMargin: 4.0px; - --requests-stageGlyph: 28.0px; - --requests-stageGap: 10.0px; - --requests-readinessGapX: 22.0px; - --requests-readinessGapY: 10.0px; - --requests-readinessMargin: 8.0px; - --requests-readinessGlyph: 26.0px; - --requests-chevron: 30.0px; - --requests-receiptPaddingY: 28.0px; - --requests-receiptPaddingX: 30.0px; - --requests-receiptGap: 10.0px; - --requests-receiptGlyph: 36.0px; - --requests-buttonMargin: 12.0px; - --requests-skeletonTitleWidth: 360.0px; - --requests-skeletonTitleHeight: 34.0px; - --requests-skeletonSubtitleWidth: 240.0px; - --requests-skeletonSubtitleHeight: 26.0px; - --requests-skeletonGap: 12.0px; - --requests-ratingsGap: 20.0px; - --requests-ratingsEntryGap: 8.0px; - --requests-shadowBlur: 80.0px; - --requests-shadowOffset: -30.0px; - --requests-shadowColor: #00000080; - --shell-navLabelFade: 200ms; - --shell-zero: 0.0px; - --shell-stageWidth: 1920.0px; - --shell-stageHeight: 1080.0px; - --shell-wordmarkWidth: 169.0px; - --shell-wordmarkTop: 73.6px; - --shell-navHomeWidth: 149.0px; - --shell-navDiscoverWidth: 198.0px; - --shell-navMoviesWidth: 171.0px; - --shell-navShowsWidth: 161.0px; - --shell-navAnimeWidth: 158.0px; - --shell-navItemTop: 65.0px; - --shell-pickerTitleTop: 272.3px; - --shell-pickerPeopleTop: 425.5px; - --shell-pickerNameGap: 26.0px; - --shell-pickerGearTop: 751.7px; - --shell-pickerGearCaptionTop: 72.0px; - --shell-pickerLetter: 100.0px; - --shell-pickerSkeletonNameWidth: 140.0px; - --shell-pickerSkeletonNameHeight: 40.0px; - --shell-pickerLoadingTitleTop: 247.4px; - --shell-pickerLoadingPeopleTop: 400.6px; - --shell-pickerLoadingButtonTop: 756.6px; - --shell-pickerErrorTitleTop: 297.6px; - --shell-pickerErrorPanelTop: 450.8px; - --shell-pickerErrorPanelWidth: 980.0px; - --shell-pickerErrorPanelHeight: 185.6px; - --shell-pickerErrorButtonTop: 706.4px; - --shell-settingsHeaderTop: 150.0px; - --shell-settingsHeaderAvatar: 52.0px; - --shell-settingsHeaderGap: 24.0px; - --shell-settingsRowTop: 10.0px; - --shell-settingsRowPadding: 30.0px; - --shell-settingsRowGap: 24.0px; - --shell-settingsIcon: 36.0px; - --shell-settingsPillHeight: 40.0px; - --shell-settingsPillStroke: 2.0px; - --shell-settingsPillBorder: #ffffff38; - --shell-settingsPillFocusedBorder: #0b0b1066; - --shell-settingsPillPadding: 16.0px; - --shell-settingsPillGap: 12.0px; - --shell-settingsChevron: 30.0px; - --shell-settingsEndPadding: 620.0px; - --shell-panelPaddingX: 30.0px; - --shell-panelPaddingY: 28.0px; - --shell-panelGap: 10.0px; - --shell-panelHeadingGap: 14.0px; - --shell-panelIcon: 36.0px; - --shell-skeletonRadius: 10.0px; - --library-chipHeight: 68.0px; - --library-chipPadding: 28.0px; - --library-chipGap: 16.0px; - --library-chipTop: 16.0px; - --library-chipIcon: 26.0px; - --library-captionWidth: 640.0px; - --library-captionFactsGap: 18.0px; - --library-badgeIcon: 28.0px; - --library-plateTop: 14.0px; - --library-plateSide: 16.0px; - --library-plateBottom: 46.0px; - --library-plateInlineDescent: 6.6px; - --library-plateFloor: 24.0px; - --library-railWidth: 52.0px; - --library-railLetterHeight: 30.0px; - --library-railFocusHeight: 44.0px; - --library-railLetter: 24.0px; - --library-railFocusedLetter: 28.0px; - --library-panelPaddingX: 40.0px; - --library-panelPaddingY: 34.0px; - --library-panelTextGap: 24.0px; - --library-panelTitleGap: 8.0px; - --library-panelIconGap: 14.0px; - --library-panelTop: 20.0px; - --library-borderWidth: 2.0px; - --library-chipContentGap: 12.0px; - --library-tailIcon: 44.0px; - --library-tailPadding: 20.0px; - --library-tailGap: 10.0px; - --library-badgeRing: 3.0px; - --library-seeAllGap: 8.0px; - --library-tailDescription: 24.0px; - --library-headerSubtitle: 30.0px; - --library-fallbackPadding: 22.0px; - --library-fallbackIcon: 44.0px; - --lift-cardBlur: 50.0px; - --lift-cardY: 18.0px; - --lift-buttonBlur: 40.0px; - --lift-buttonY: 12.0px; - --lift-heroBlur: 80.0px; - --lift-heroY: 30.0px; - --lift-sheetBlur: 80.0px; - --lift-sheetX: -30.0px; - --title-lineGap: 22.0px; - --title-lineLift: -4.0px; - --title-overviewGap: 34.0px; - --title-factsGap: 22.0px; - --title-factsBottom: 14.0px; - --title-factsHeight: 38.0px; - --title-titleBottom: 16.0px; - --title-iconGap: 14.0px; - --title-iconSize: 32.0px; - --title-captionTop: 16.0px; - --title-progressWidth: 320.0px; - --title-progressBottom: 30.0px; - --title-progressGap: 18.0px; - --title-progressLift: -14.0px; - --title-castHeight: 462.0px; - --title-castNameTop: 236.0px; - --title-castNameSide: 12.0px; - --title-personTop: 170.0px; - --title-personGap: 48.0px; - --title-personNameBottom: 10.0px; - --title-personNoteBottom: 6.0px; - --title-captionWidth: 640.0px; - --title-sheetPaddingTop: 80.0px; - --title-sheetPaddingSide: 64.0px; - --title-sheetGap: 18.0px; - --title-sheetSubLift: -8.0px; - --title-sheetSubBottom: 14.0px; - --title-sheetLabelTop: 8.0px; - --title-sheetButtonTop: 12.0px; - --title-sheetRowSide: 26.0px; - --title-versionRowVertical: 20.0px; - --title-trackHeight: 84.0px; - --title-trackGap: 12.0px; - --title-radioSize: 40.0px; - --title-radioDot: 16.0px; - --title-radioBorder: 3.0px; - --title-infoGap: 14.0px; - --title-infoLabelWidth: 200.0px; - --title-infoRowGap: 6.0px; - --title-infoColumnGap: 20.0px; - --title-infoKey: 24.0px; - --title-gridHeight: 524.0px; - --title-minimumEndPadding: 80.0px; - --title-ratingGap: 20.0px; - --title-ratingMarkGap: 8.0px; - --title-initialSize: 64.0px; - --title-personInitialSize: 96.0px; - --title-skeletonKickerWidth: 420.0px; - --title-skeletonKickerHeight: 34.0px; - --title-skeletonTitleGap: 26.0px; - --title-skeletonFactsGap: 24.0px; - --title-skeletonOverviewGap: 30.0px; - --title-skeletonTitleWidth: 760.0px; - --title-skeletonTitleHeight: 92.0px; - --title-skeletonFactsWidth: 560.0px; - --title-skeletonOverviewWidth: 980.0px; - --title-skeletonOverviewHeight: 110.0px; - --title-skeletonPlayWidth: 220.0px; - --title-dLine: 26.0px; - --title-iconCaption: 26.0px; - --title-lineIcon: 28.0px; - --title-chipHeight: 68.0px; - --title-chipPadding: 28.0px; - --title-chipGap: 16.0px; - --title-chipTop: 16.0px; - --title-chipBorder: 2.0px; - --title-heroSheetRight: 180.0px; - --title-heroSheetTop: 150.0px; - --title-gridHeadingTop: 24.0px; - --title-castName: 26.0px; - --title-receiptSide: 30.0px; - --title-receiptTop: 28.0px; - --title-receiptGap: 10.0px; - --title-receiptIcon: 36.0px; - --title-receiptTitle: 36.0px; - --title-toastMaxWidth: 760.0px; - --title-toastSide: 28.0px; - --title-toastTop: 22.0px; - --title-heroAmbientBackground: radial-gradient(120% 90% at 70% 20%,var(--surface3),var(--bg)); - --title-errorTop: 170.0px; - --title-errorSide: 40.0px; - --title-errorVertical: 34.0px; - --title-errorHeadingBottom: 8.0px; - --title-errorBodyBottom: 24.0px; - --title-errorTitle: 36.0px; - --title-fallbackGlyph: 44.0px; - --title-fallbackInset: 22.0px; - --title-tileSheetInset: 18.0px; - --title-tileSheetLeft: 22.0px; - --title-tileSheetRadius: 10.0px; - --title-tileAmbientBlur: 26.0px; - --title-tileAmbientInset: 30.0px; - --title-tileShadowRadius: 30.0px; - --title-tileShadowY: 10.0px; - --title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%)); - --title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); - --title-initialsColor: #ffffffd1; - --title-fallbackGlyphColor: #ffffff73; - --title-fallbackTitle: 28.0px; - --title-tileOverlayTop: 54.0px; - --title-tileOverlaySide: 22.0px; - --title-tileOverlayBottom: 18.0px; - --title-cardProgressInset: 18.0px; - --title-tileProgressBottom: 10.0px; - --title-ambientInset: 80.0px; - --title-posterShadowRadius: 80.0px; - --title-posterShadowY: 30.0px; - --title-sheetShadowRadius: 80.0px; - --title-sheetShadowX: -30.0px; - --savedIndicator-height: 52.0px; - --savedIndicator-side: 20.0px; - --savedIndicator-gap: 10.0px; - --savedIndicator-icon: 28.0px; - --savedIndicator-border: 2.0px; - --home-kickerGap: 12.0px; - --home-kickerBottom: 10.0px; - --home-titleBottom: 16.0px; - --home-factsHeight: 38.0px; - --home-factsBottom: 14.0px; - --home-factsGap: 22.0px; - --home-overviewBottom: 30.0px; - --home-cardPadding: 22.0px; - --home-cardOverlayTop: 54.0px; - --home-cardOverlayBottom: 18.0px; - --home-sectionTop: 40.0px; - --home-sectionAvatar: 56.0px; - --home-sectionGap: 22.0px; - --home-stateTextBottom: 24.0px; - --home-stateTop: 20.0px; - --home-statePaddingX: 40.0px; - --home-statePaddingY: 34.0px; - --home-stateGap: 8.0px; - --home-stateIcon: 36.0px; - --home-captionWidth: 640.0px; - --home-plateTop: 14.0px; - --home-plateSide: 16.0px; - --home-plateFade: 46.0px; - --home-ratingGap: 20.0px; - --home-ratingMarkGap: 8.0px; - --home-heroSkeletonWidth: 640.0px; - --home-heroSkeletonHeight: 84.0px; - --home-heroSkeletonFactsWidth: 460.0px; - --home-heroSkeletonFactsHeight: 34.0px; - --home-heroSkeletonButtonWidth: 260.0px; - --home-heroSkeletonGap: 22.0px; - --home-heroSkeletonButtonGap: 40.0px; - --home-artGlyph: 44.0px; - --home-cardSheetLeft: 22.0px; - --home-cardSheetTop: 18.0px; - --home-cardSheetInset: 36.0px; - --home-heroSheetRight: 180.0px; - --home-heroSheetTop: 150.0px; - --home-cardBlur: 26.0px; - --home-heroBlur: 60.0px; - --home-progressInset: 18.0px; - --home-progressBottom: 10.0px; - --home-endTop: 40.0px; - --home-frameBottom: 80.0px; - --home-toastPadding: 28.0px; - --home-toastTop: 150.0px; - --home-toastWidth: 760.0px; - --home-toastPaddingY: 22.0px; - --home-toastGap: 14.0px; - --home-toastShadowBlur: 60.0px; - --home-toastShadowY: 20.0px; - --home-cardWashInset: 30.0px; - --home-heroWashInset: 80.0px; - --home-noteTop: 14.0px; - --home-noteGap: 16.0px; - --home-hairline: 2.0px; - --home-savedHeight: 52.0px; - --home-savedPadding: 20.0px; - --home-savedGap: 10.0px; - --home-savedIcon: 28.0px; - --home-savedRadius: 28.0px; - --home-posterProgressBottom: 12.0px; - --discover-sheetPaddingX: 64.0px; - --discover-sheetPaddingY: 80.0px; - --discover-sheetGap: 18.0px; - --discover-sheetSubGap: 14.0px; - --discover-sheetRowPadding: 26.0px; - --discover-sheetAvatar: 60.0px; - --discover-sheetRadio: 40.0px; - --discover-sheetRadioDot: 16.0px; - --discover-sheetPersonGap: 24.0px; - --discover-sheetButtonTop: 12.0px; - --discover-chipTop: 16.0px; - --discover-chipHeight: 68.0px; - --discover-chipPadding: 28.0px; - --discover-chipGap: 16.0px; - --discover-heroTitleGap: 16.0px; - --discover-heroKickerGap: 10.0px; - --discover-heroMetaGap: 14.0px; - --discover-heroOverviewGap: 30.0px; - --discover-heroFactsGap: 22.0px; - --discover-heroFactsHeight: 38.0px; - --discover-heroSheetRight: 180.0px; - --discover-heroSheetTop: 140.0px; - --discover-heroSheetWidth: 360.0px; - --discover-heroSheetHeight: 540.0px; - --discover-dotSize: 14.0px; - --discover-dotActive: 44.0px; - --discover-dotGap: 14.0px; - --discover-dotMargin: 18.0px; - --discover-dotChevron: 40.0px; - --discover-stateTop: 20.0px; - --discover-statePaddingX: 40.0px; - --discover-statePaddingY: 34.0px; - --discover-stateTextGap: 24.0px; - --discover-noteTop: 14.0px; - --discover-headerTop: 150.0px; - --discover-headerGap: 24.0px; - --discover-entryPaddingX: 22.0px; - --discover-entryPaddingY: 20.0px; - --discover-entryText: 30.0px; - --discover-sheetRadioRing: 3.0px; - --discover-skeletonNameWidth: 260.0px; - --discover-skeletonNameHeight: 34.0px; - --discover-sheetSubTop: -8.0px; - --discover-sheetRatingTop: -10.0px; - --discover-sheetRatingBottom: 6.0px; - --discover-sheetLabelTop: 8.0px; - --discover-noteIcon: 30.0px; - --discover-noteGap: 16.0px; - --discover-frameBottomMin: 80.0px; - --discover-anchorFitTop: 700.0px; - --discover-anchorBottomClearance: 40.0px; - --discover-stageGap: 10.0px; - --discover-ambientInset: 80.0px; - --discover-ambientBlur: 60.0px; -} diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json deleted file mode 100644 index f8121dc4..00000000 --- a/wolfskin-design/tokens.json +++ /dev/null @@ -1,1616 +0,0 @@ -{ - "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", - "meta": { - "density": 2.0, - "stagePx": [ - 1920, - 1080 - ], - "stageDp": [ - 960, - 540 - ], - "source": "data/tv-v2/focus-2026-11-06/index.html", - "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", - "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" - }, - "color": { - "bg": { - "$type": "color", - "$value": "#07070a", - "css": "#07070a", - "source": "index.html:10", - "usedBy": "app background; stage; hero/detail scrim end; nav band when solid" - }, - "surface": { - "$type": "color", - "$value": "#13131a", - "css": "#13131a", - "source": "index.html:10", - "usedBy": "sheets (side panels), state panels, See all card, skeleton blocks, Try again/Show more tail cards" - }, - "surface2": { - "$type": "color", - "$value": "#1b1b25", - "css": "#1b1b25", - "source": "index.html:10", - "usedBy": "card art placeholder behind images; menus; toast; person-page portrait placeholder" - }, - "surface3": { - "$type": "color", - "$value": "#252532", - "css": "#252532", - "source": "index.html:10", - "usedBy": "hero radial placeholder; Coming-in thumbnail placeholder" - }, - "fg": { - "$type": "color", - "$value": "#f4f4f6", - "css": "#f4f4f6", - "source": "index.html:11", - "usedBy": "primary text; primary button fill; focused nav blob; focused chip/row/list fill (inverse focus); white focus ring on avatars and the gear" - }, - "muted": { - "$type": "color", - "$value": "#f4f4f6bd", - "css": "rgb(244 244 246 / .74)", - "source": "index.html:11", - "usedBy": "secondary text: facts lines, overviews, captions under cards, unfocused nav labels, row descriptions" - }, - "dim": { - "$type": "color", - "$value": "#f4f4f68f", - "css": "rgb(244 244 246 / .56)", - "source": "index.html:11", - "usedBy": "tertiary text: sheet labels, small print, pill text, the resting gear" - }, - "border": { - "$type": "color", - "$value": "#ffffff1a", - "css": "rgb(255 255 255 / .10)", - "source": "index.html:11", - "usedBy": "hairline borders on See all/tail cards and state panels" - }, - "accent": { - "$type": "color", - "$value": "#cc9fff", - "css": "oklch(78% 0.14 305)", - "source": "index.html:12", - "usedBy": "lilac focus ring on cards and buttons; progress fills; current-destination underline; See all glyph; requested badge ring; Downloading/Almost ready stage words" - }, - "accentSoft": { - "$type": "color", - "$value": "#2c1a3f", - "css": "oklch(26% 0.07 305)", - "source": "index.html:12", - "usedBy": "selected chip fill (chip.on)" - }, - "accentText": { - "$type": "color", - "$value": "#e4cbff", - "css": "oklch(88% 0.08 305)", - "source": "index.html:12", - "usedBy": "kicker line above hero titles; d-line on title pages; availability words in captions; selected chip text; wordmark \"skin\"" - }, - "ok": { - "$type": "color", - "$value": "#89da9b", - "css": "oklch(82% 0.12 150)", - "source": "index.html:13", - "usedBy": "held badge (green check); Ready stage word; readiness Done" - }, - "warn": { - "$type": "color", - "$value": "#f4c26a", - "css": "oklch(84% 0.12 80)", - "source": "index.html:13", - "usedBy": "Saved HH:MM chip; warning state titles and icons; toast icon; Try again tail glyph" - }, - "fail": { - "$type": "color", - "$value": "#f97770", - "css": "oklch(72% 0.16 25)", - "source": "index.html:13", - "usedBy": "failed-request badge ring; Failed stage word; Refused receipt title" - }, - "onFocus": { - "$type": "color", - "$value": "#0b0b10", - "css": "#0b0b10", - "source": "index.html:84", - "usedBy": "text and icons on any white focus fill: focused nav item, primary button label, focused chip/row/list item, avatar letters" - }, - "buttonSecondary": { - "$type": "color", - "$value": "#ffffff24", - "css": "rgb(255 255 255 / .14)", - "source": "index.html:107", - "usedBy": "secondary (non-primary) pill button at rest on hero rows" - }, - "buttonSecondaryFocused": { - "$type": "color", - "$value": "#ffffff42", - "css": "rgb(255 255 255 / .26)", - "source": "index.html:111", - "usedBy": "secondary pill button while focused (plus the accent ring)" - }, - "titleButtonSecondary": { - "$type": "color", - "$value": "#2c2c38c7", - "css": "rgb(44 44 56 / .78)", - "source": "index.html:279", - "usedBy": "title-page secondary and icon buttons at rest (dark backing so labels read over bright art)" - }, - "titleButtonSecondaryFocused": { - "$type": "color", - "$value": "#464654e6", - "css": "rgb(70 70 84 / .9)", - "source": "index.html:280", - "usedBy": "title-page secondary and icon buttons while focused" - }, - "chipRest": { - "$type": "color", - "$value": "#ffffff1a", - "css": "rgb(255 255 255 / .10)", - "source": "index.html:185", - "usedBy": "filter/sort/season chips at rest" - }, - "navBlobRest": { - "$type": "color", - "$value": "#ffffff1a", - "css": "rgb(255 255 255 / .10)", - "source": "index.html:76", - "usedBy": "nav indicator resting behind the current destination while focus is elsewhere" - }, - "listRowRest": { - "$type": "color", - "$value": "#ffffff0d", - "css": "rgb(255 255 255 / .05)", - "source": "index.html:231", - "usedBy": "sheet rows (person, version, track) at rest; Settings rows (.srow) use the same value" - }, - "sheetScrim": { - "$type": "color", - "$value": "#0000009e", - "css": "rgb(0 0 0 / .62)", - "source": "index.html:226", - "usedBy": "full-screen scrim behind every side sheet" - }, - "badgeRest": { - "$type": "color", - "$value": "#07070abf", - "css": "rgb(7 7 10 / .75)", - "source": "index.html:141", - "usedBy": "badge disc behind the requested/failed glyph" - }, - "badgeWatched": { - "$type": "color", - "$value": "#f4f4f6d9", - "css": "rgb(244 244 246 / .85)", - "source": "index.html:145", - "usedBy": "watched badge disc (defined in CSS; no surface draws it in the locked page) [unused]" - }, - "progressTrack": { - "$type": "color", - "$value": "#ffffff38", - "css": "rgb(255 255 255 / .22)", - "source": "index.html:138", - "usedBy": "progress-bar track on wide cards; title-page progress track uses rgb(255 255 255 / .2) (.d-prog i)" - }, - "railEmpty": { - "$type": "color", - "$value": "#f4f4f638", - "css": "rgb(244 244 246 / .22)", - "source": "index.html:206", - "usedBy": "letter rail: letters with no titles (rail shown only at >= 200 titles)" - } - }, - "personColor": { - "justin": { - "$type": "color", - "$value": "#62c4ff", - "initial": "J", - "source": "fixture/fixture.json profiles[].color", - "usedBy": "Justin: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" - }, - "mindel": { - "$type": "color", - "$value": "#ff9d72", - "initial": "M", - "source": "fixture/fixture.json profiles[].color", - "usedBy": "Mindel: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" - }, - "shared": { - "$type": "color", - "$value": "#9be38f", - "initial": "S", - "source": "fixture/fixture.json profiles[].color", - "usedBy": "Shared: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" - } - }, - "gradient": { - "heroScrim": { - "$type": "gradient", - "$value": "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)", - "source": "index.html:99", - "usedBy": "Home hero and Discover picks hero: left-to-right darkening (92% at 0, 55% at 42%, 0 at 70%) plus bottom fade into bg (0..48%)" - }, - "detailScrim": { - "$type": "gradient", - "$value": "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)", - "source": "index.html:210", - "usedBy": "title page backdrop scrim (94% / 60% at 46% / 5% at 75%; bottom fade to 55%)" - }, - "navBand": { - "$type": "gradient", - "$value": "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))", - "source": "index.html:71", - "usedBy": "nav band at scrollTop <= 40: 72% bg fading to 0 over 150 px" - }, - "navBandSolid": { - "$type": "gradient", - "$value": "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)", - "source": "index.html:72", - "usedBy": "nav band once the frame scrolled > 40 px: solid bg to 84% of 150 px, then fades" - }, - "ratingPlate": { - "$type": "gradient", - "$value": "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)", - "source": "index.html:162", - "usedBy": "rating plate on the focused card: 90% bg at top, 72% at 52%, 0 at bottom" - }, - "tileOverlay": { - "$type": "gradient", - "$value": "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))", - "source": "index.html:135", - "usedBy": "bottom gradient under tile/wide titles" - }, - "fallbackArt": { - "$type": "gradient", - "$value": "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))", - "source": "index.html:130", - "usedBy": "no-art card: 160deg two-tone gradient, hue = hash(title) (hueOf, index.html:744)" - }, - "entryTile": { - "$type": "gradient", - "$value": "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))", - "source": "index.html:149", - "usedBy": "service entry tiles: 150deg gradient, hue per service (SERVICE_HUE, index.html:663)" - }, - "initialsCircle": { - "$type": "gradient", - "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", - "source": "index.html:288", - "usedBy": "cast circle / person portrait without a photo: initials on a 160deg tinted gradient, hue = hash(name)" - }, - "heroAmbientFilter": { - "$type": "filter", - "$value": "blur(60px) saturate(1.2) brightness(.55)", - "inset": "-80px", - "scale": 1.1, - "source": "index.html:96", - "usedBy": "poster-only hero/title art: blurred wash of the poster behind the one-sheet (Compose: RenderEffect blur 30 dp, saturation 1.2, brightness .55) [INFERENCE: Compose mapping]" - }, - "cardAmbientFilter": { - "$type": "filter", - "$value": "blur(26px) brightness(.5)", - "inset": "-30px", - "source": "index.html:129", - "usedBy": "poster-only art in a landscape card: blurred wash behind the one-sheet" - } - }, - "font": { - "family": { - "$type": "fontFamily", - "$value": "system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",Ubuntu,Cantarell,sans-serif", - "source": "index.html:14", - "usedBy": "all stage text", - "note": "On the box this resolves to the platform sans (Roboto) [INFERENCE]; Compose uses the default FontFamily." - }, - "tabularNumbers": { - "$value": "font-variant-numeric: tabular-nums", - "source": "index.html:156", - "usedBy": "every ratings row" - } - }, - "type": { - "displayDetail": { - "$type": "typography", - "px": 88.0, - "sp": 44.0, - "source": "index.html:213", - "usedBy": "title page title (film, series, episode = series name); clamp 2 lines", - "weight": 800, - "weightSource": "index.html:103", - "lineHeight": 1.02, - "lineHeightSource": "index.html:103", - "letterSpacingEm": -0.02, - "$value": { - "fontSize": "44sp", - "fontWeight": 800 - } - }, - "displayHero": { - "$type": "typography", - "px": 84.0, - "sp": 42.0, - "source": "index.html:103", - "usedBy": "Home hero and Discover picks hero title; clamp 2 lines", - "weight": 800, - "weightSource": "index.html:103", - "lineHeight": 1.02, - "lineHeightSource": "index.html:103", - "letterSpacingEm": -0.02, - "$value": { - "fontSize": "42sp", - "fontWeight": 800 - } - }, - "screenTitle": { - "$type": "typography", - "px": 64.0, - "sp": 32.0, - "source": "index.html:200", - "usedBy": "screen headers: library (Movies/Shows/Anime), See all, service page, Settings, Discover-without-hero; also \"Who's watching?\" (.picker h1) and person name (.p-head h1)", - "weight": 800, - "weightSource": "index.html:200", - "letterSpacingEm": -0.02, - "$value": { - "fontSize": "32sp", - "fontWeight": 800 - } - }, - "sectionTitle": { - "$type": "typography", - "px": 56.0, - "sp": 28.0, - "source": "index.html:181", - "usedBy": "the \"Discover\" section header inside Home; player hand-off title (.player h2)", - "weight": 800, - "weightSource": "index.html:181", - "letterSpacingEm": -0.02, - "$value": { - "fontSize": "28sp", - "fontWeight": 800 - } - }, - "sheetTitle": { - "$type": "typography", - "px": 44.0, - "sp": 22.0, - "source": "index.html:228", - "usedBy": "side-sheet titles (Request, Version, Coming in, ...)", - "weight": 800, - "weightSource": "index.html:228", - "letterSpacingEm": -0.01, - "$value": { - "fontSize": "22sp", - "fontWeight": 800 - } - }, - "wordmark": { - "$type": "typography", - "px": 36.0, - "sp": 18.0, - "source": "index.html:79", - "usedBy": "the \"Wolfskin\" wordmark at the left of the nav and in the picker", - "weight": 800, - "weightSource": "index.html:79", - "letterSpacingEm": -0.02, - "$value": { - "fontSize": "18sp", - "fontWeight": 800 - } - }, - "stateTitle": { - "$type": "typography", - "px": 36.0, - "sp": 18.0, - "source": "index.html:195", - "usedBy": "state panel titles and receipt titles (.receipt h3)", - "$value": { - "fontSize": "18sp", - "fontWeight": 400 - } - }, - "rowHeading": { - "$type": "typography", - "px": 34.0, - "sp": 17.0, - "source": "index.html:119", - "usedBy": "row headings above every shelf and grid section", - "weight": 700, - "weightSource": "index.html:119", - "letterSpacingEm": -0.01, - "$value": { - "fontSize": "17sp", - "fontWeight": 700 - } - }, - "pickerName": { - "$type": "typography", - "px": 34.0, - "sp": 17.0, - "source": "index.html:260", - "usedBy": "names under avatars in Who's watching", - "weight": 650, - "weightSource": "index.html:260", - "$value": { - "fontSize": "17sp", - "fontWeight": 650 - } - }, - "nav": { - "$type": "typography", - "px": 30.0, - "sp": 15.0, - "source": "index.html:81", - "usedBy": "nav destination labels", - "weight": 600, - "weightSource": "index.html:81", - "$value": { - "fontSize": "15sp", - "fontWeight": 600 - } - }, - "listTitle": { - "$type": "typography", - "px": 30.0, - "sp": 15.0, - "source": "index.html:231", - "usedBy": "sheet row names; Settings row titles (.srow .tx b); Coming-in row titles (.crow .tx b); See all label; service entry wordmark", - "weight": 650, - "weightSource": "index.html:231", - "$value": { - "fontSize": "15sp", - "fontWeight": 650 - } - }, - "body": { - "$type": "typography", - "px": 28.0, - "sp": 14.0, - "source": "index.html:104", - "usedBy": "facts lines (h-meta), overviews (h-ov, d-ov), card caption title (.cap b), tile titles (.card .ov b), buttons (.btn), state text, receipts, toast, rating numbers and marks", - "$value": { - "fontSize": "14sp", - "fontWeight": 400 - } - }, - "button": { - "$type": "typography", - "px": 28.0, - "sp": 14.0, - "source": "index.html:107", - "usedBy": "pill buttons (primary and secondary)", - "weight": 700, - "weightSource": "index.html:107", - "$value": { - "fontSize": "14sp", - "fontWeight": 700 - } - }, - "label": { - "$type": "typography", - "px": 26.0, - "sp": 13.0, - "source": "index.html:185", - "usedBy": "chips; kicker (26/650); d-line; d-prog; icon-button caption (.bcap); cast name (.pnm b, 26/700); sheet section labels (.sheet .lab)", - "weight": 650, - "weightSource": "index.html:185", - "$value": { - "fontSize": "13sp", - "fontWeight": 650 - } - }, - "caption": { - "$type": "typography", - "px": 24.0, - "sp": 12.0, - "source": "index.html:154", - "usedBy": "THE FLOOR. Card facts under a focused poster; tile second lines; pills; small print; character names; readiness stages; letter rail; rating-plate floor. Nothing on the stage is smaller.", - "$value": { - "fontSize": "12sp", - "fontWeight": 400 - } - }, - "ratingPlate": { - "$type": "typography", - "px": 28.0, - "sp": 14.0, - "floorPx": 24, - "floorSp": 12.0, - "stepPx": 0.5, - "weight": 700, - "lineHeight": 1.2, - "source": "index.html:164", - "floorSource": "index.html:802", - "$value": { - "fontSize": "14sp", - "min": "12sp", - "fontWeight": 700 - }, - "usedBy": "rating plate on the focused card: starts at 28 px (14 sp, the fork titleSmall), shrinks in 0.5 px steps to the 24 px floor, then drops trailing entries (fitRow, D9)" - }, - "avatarLetter": { - "$type": "typography", - "weight": 800, - "source": "index.html:89", - "sizes": { - "picker": 100, - "nav": 30, - "sheetRow": 28, - "shelfHead": 24, - "chip": 24, - "libHeader": 26, - "sectionHeader": 28 - }, - "usedBy": "avatar letters (px per context)" - }, - "stageLineHeight": { - "$type": "number", - "$value": 1.3, - "source": "index.html:61", - "usedBy": "default line height for all stage text (1.3)" - } - }, - "radius": { - "card": { - "$type": "dimension", - "$value": "8dp", - "px": 16.0, - "dp": 8.0, - "source": "index.html:125", - "usedBy": "every card (poster, tile, wide, entry, See all, tail, skeleton)" - }, - "button": { - "$type": "dimension", - "$value": "19dp", - "px": 38.0, - "dp": 19.0, - "source": "index.html:107", - "usedBy": "pill buttons = height / 2" - }, - "chip": { - "$type": "dimension", - "$value": "17dp", - "px": 34.0, - "dp": 17.0, - "source": "index.html:185", - "usedBy": "chips = height / 2" - }, - "navItem": { - "$type": "dimension", - "$value": "16dp", - "px": 32.0, - "dp": 16.0, - "source": "index.html:81", - "usedBy": "nav item and blob = height / 2" - }, - "listRow": { - "$type": "dimension", - "$value": "12dp", - "px": 24.0, - "dp": 12.0, - "source": "index.html:231", - "usedBy": "sheet rows and Settings rows" - }, - "panel": { - "$type": "dimension", - "$value": "11dp", - "px": 22.0, - "dp": 11.0, - "source": "index.html:194", - "usedBy": "state panels, receipts, Coming-in rows, toast" - }, - "menu": { - "$type": "dimension", - "$value": "13dp", - "px": 26.0, - "dp": 13.0, - "source": "index.html:264", - "usedBy": "menu panel" - }, - "heroSheet": { - "$type": "dimension", - "$value": "9dp", - "px": 18.0, - "dp": 9.0, - "source": "index.html:97", - "usedBy": "upright poster (\"one-sheet\") on poster-only hero/title art" - }, - "cardSheet": { - "$type": "dimension", - "$value": "5dp", - "px": 10.0, - "dp": 5.0, - "source": "index.html:128", - "usedBy": "one-sheet inside a landscape card" - }, - "thumb": { - "$type": "dimension", - "$value": "6dp", - "px": 12.0, - "dp": 6.0, - "source": "index.html:248", - "usedBy": "Coming-in poster thumbnail 96x144" - }, - "pill": { - "$type": "dimension", - "$value": "11dp", - "px": 22.0, - "dp": 11.0, - "source": "index.html:122", - "usedBy": "sample/Saved/Wolfskin/upstream pills" - }, - "progress": { - "$type": "dimension", - "$value": "2dp", - "px": 4.0, - "dp": 2.0, - "source": "index.html:138", - "usedBy": "progress bars" - }, - "circle": { - "$type": "dimension", - "$value": "50%", - "source": "index.html:89", - "usedBy": "avatars, badges, person circles, gear" - } - }, - "layout": { - "safeSide": { - "$type": "dimension", - "$value": "48dp", - "px": 96, - "dp": 48.0, - "source": "index.html:654", - "usedBy": "left/right safe inset on every screen (5% of 1920); first card x, all copy blocks" - }, - "contentWidth": { - "$type": "dimension", - "$value": "864dp", - "px": 1728, - "dp": 864.0, - "source": "index.html:1035", - "usedBy": "content measure 1920 - 2 x 96; slotsOf() = floor((1728 + gap + 8) / (w + gap))" - }, - "safeTopPicker": { - "$type": "dimension", - "$value": "27dp", - "px": 54.0, - "dp": 27.0, - "source": "index.html:258", - "usedBy": "top inset of the wordmark in Who's watching (5% of 1080)" - }, - "navBand": { - "$type": "dimension", - "$value": "75dp", - "px": 150.0, - "dp": 75.0, - "source": "index.html:71", - "usedBy": "nav band height on every non-full-screen screen" - }, - "navPaddingTop": { - "$type": "dimension", - "$value": "22dp", - "px": 44.0, - "dp": 22.0, - "source": "index.html:74", - "usedBy": "nav row top padding; items are vertically centred in 44..150, so a 64 px item spans y 65..129 (measured)" - }, - "navItemHeight": { - "$type": "dimension", - "$value": "32dp", - "px": 64.0, - "dp": 32.0, - "source": "index.html:81", - "usedBy": "nav item / blob height; nav avatar is 64x64" - }, - "navItemPaddingX": { - "$type": "dimension", - "$value": "13dp", - "px": 26.0, - "dp": 13.0, - "source": "index.html:81", - "usedBy": "nav item horizontal padding" - }, - "navItemGap": { - "$type": "dimension", - "$value": "6dp", - "px": 12.0, - "dp": 6.0, - "source": "index.html:74", - "usedBy": "gap between nav items" - }, - "wordmarkGap": { - "$type": "dimension", - "$value": "20dp", - "px": 40.0, - "dp": 20.0, - "source": "index.html:79", - "usedBy": "space after the wordmark before Home" - }, - "navUnderline": { - "$type": "dimension", - "$value": "2dp", - "px": 4.0, - "dp": 2.0, - "source": "index.html:83", - "usedBy": "accent underline under the current destination, inset 26 px each side, 4 px above the item bottom; hidden while that item is focused" - }, - "navBlobAvatarPad": { - "$type": "dimension", - "$value": "4dp", - "px": 8, - "dp": 4.0, - "source": "index.html:1681", - "usedBy": "the blob grows 8 px each side around the avatar (80x80 circle)" - }, - "navSolidAfter": { - "$type": "dimension", - "$value": "20dp", - "px": 40, - "dp": 20.0, - "source": "index.html:1668", - "usedBy": "the nav band switches to the solid gradient once the frame scrollTop > 40" - }, - "pinnedLine": { - "$type": "dimension", - "$value": "84dp", - "px": 168, - "dp": 84.0, - "source": "index.html:654", - "usedBy": "focus-follow: a focused row (not hero) scrolls so its heading top sits 168 px from the top; rows whose bottom fits the first screen (y < 700 and y + h <= 1040) do not scroll" - }, - "windowAhead": { - "$type": "dimension", - "$value": "350dp", - "px": 700, - "dp": 350.0, - "source": "index.html:654", - "usedBy": "prototype windowing distance (rows within 700 px of the viewport exist); in Compose lazy layouts do this" - }, - "rowHeadingBand": { - "$type": "dimension", - "$value": "42dp", - "px": 84, - "dp": 42.0, - "source": "index.html:1018", - "usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)" - }, - "rowGapAfter": { - "$type": "dimension", - "$value": "34dp", - "px": 68, - "dp": 34.0, - "source": "index.html:1018", - "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68" - }, - "gridRowGap": { - "$type": "dimension", - "$value": "22dp", - "px": 44, - "dp": 22.0, - "source": "index.html:1019", - "usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)" - }, - "row_hero": { - "$type": "dimension", - "$value": "410dp", - "px": 820, - "dp": 410.0, - "source": "index.html:1016", - "usedBy": "Home hero row height (the hero art itself is 1920x1080 behind it)" - }, - "row_phero": { - "$type": "dimension", - "$value": "370dp", - "px": 740, - "dp": 370.0, - "source": "index.html:1017", - "usedBy": "Discover picks-hero row height" - }, - "row_section": { - "$type": "dimension", - "$value": "75dp", - "px": 150, - "dp": 75.0, - "source": "index.html:1020", - "usedBy": "Home \"Discover\" section header row" - }, - "row_chips": { - "$type": "dimension", - "$value": "52dp", - "px": 104, - "dp": 52.0, - "source": "index.html:1021", - "usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px" - }, - "row_note": { - "$type": "dimension", - "$value": "42dp", - "px": 84, - "dp": 42.0, - "source": "index.html:1022", - "usedBy": "one-line note row (picks unavailable, no seeds, episode note)" - }, - "row_state": { - "$type": "dimension", - "$value": "150dp", - "px": 300, - "dp": 150.0, - "source": "index.html:1023", - "usedBy": "state panel row (calm or warning, with recovery buttons)" - }, - "row_lhead": { - "$type": "dimension", - "$value": "118dp", - "px": 236, - "dp": 118.0, - "source": "index.html:1024", - "usedBy": "screen header row (library, See all, service, Settings, Discover without hero)" - }, - "row_dhead": { - "$type": "dimension", - "$value": "450dp", - "px": 900, - "dp": 450.0, - "source": "index.html:1025", - "usedBy": "title page header row (copy + buttons over the backdrop)" - }, - "row_phead": { - "$type": "dimension", - "$value": "235dp", - "px": 470, - "dp": 235.0, - "source": "index.html:1026", - "usedBy": "person page header row" - }, - "row_gh": { - "$type": "dimension", - "$value": "42dp", - "px": 84, - "dp": 42.0, - "source": "index.html:1027", - "usedBy": "grid section heading row (person page)" - }, - "row_endcta": { - "$type": "dimension", - "$value": "100dp", - "px": 200, - "dp": 100.0, - "source": "index.html:1028", - "usedBy": "Home end action row (\"Explore more in Discover\")" - }, - "row_srow": { - "$type": "dimension", - "$value": "62dp", - "px": 124, - "dp": 62.0, - "source": "index.html:1029", - "usedBy": "Settings list row pitch (row 104 + 20)" - }, - "heroCopyLeft": { - "$type": "dimension", - "$value": "48dp", - "px": 96.0, - "dp": 48.0, - "source": "index.html:100", - "usedBy": "hero copy block x" - }, - "heroCopyBottom": { - "$type": "dimension", - "$value": "55dp", - "px": 110.0, - "dp": 55.0, - "source": "index.html:100", - "usedBy": "hero copy block anchored 110 px above the 1080 art bottom (bottom edge of buttons at y 710 on Home)" - }, - "heroCopyWidth": { - "$type": "dimension", - "$value": "500dp", - "px": 1000.0, - "dp": 500.0, - "source": "index.html:100", - "usedBy": "hero copy block width" - }, - "heroOverviewMax": { - "$type": "dimension", - "$value": "480dp", - "px": 960.0, - "dp": 480.0, - "source": "index.html:105", - "usedBy": "hero overview max width; 2-line clamp" - }, - "detailCopyTop": { - "$type": "dimension", - "$value": "100dp", - "px": 200.0, - "dp": 100.0, - "source": "index.html:212", - "usedBy": "title page copy block top" - }, - "detailCopyWidth": { - "$type": "dimension", - "$value": "530dp", - "px": 1060.0, - "dp": 530.0, - "source": "index.html:212", - "usedBy": "title page copy block width; overview 3-line clamp" - }, - "heroSheet": { - "$type": "dimension", - "px": [ - 420, - 630 - ], - "dp": [ - 210, - 315 - ], - "right": 180, - "top": 150, - "source": "index.html:97", - "usedBy": "one-sheet poster on a poster-only Home hero/title page; Discover picks hero uses 360x540 at top 140 (.row-phero .art-sheet)" - }, - "buttonHeight": { - "$type": "dimension", - "$value": "38dp", - "px": 76.0, - "dp": 38.0, - "source": "index.html:107", - "usedBy": "pill button height (primary, secondary, sheet buttons)" - }, - "buttonPaddingX": { - "$type": "dimension", - "$value": "19dp", - "px": 38.0, - "dp": 19.0, - "source": "index.html:107", - "usedBy": "pill button horizontal padding" - }, - "buttonGap": { - "$type": "dimension", - "$value": "10dp", - "px": 20.0, - "dp": 10.0, - "source": "index.html:106", - "usedBy": "gap between buttons in a row" - }, - "buttonIcon": { - "$type": "dimension", - "$value": "15dp", - "px": 30.0, - "dp": 15.0, - "source": "index.html:108", - "usedBy": "glyph inside a pill button; icon gap 14" - }, - "iconButton": { - "$type": "dimension", - "$value": "38dp", - "px": 76.0, - "dp": 38.0, - "source": "index.html:274", - "usedBy": "round icon button (Restart, Version, Audio and subtitles, Mark watched, Trailer, More); glyph 32" - }, - "iconCaptionLine": { - "$type": "dimension", - "$value": "19dp", - "px": 38.0, - "dp": 19.0, - "source": "index.html:281", - "usedBy": "reserved caption line under a button row that has icon buttons (margin-top 16); shows \"label · note\" of the focused icon button" - }, - "sheetWidth": { - "$type": "dimension", - "$value": "400dp", - "px": 800.0, - "dp": 400.0, - "source": "index.html:227", - "usedBy": "right-side sheet width; padding 80 top/bottom, 64 sides; vertical gap 18" - }, - "sheetRow": { - "$type": "dimension", - "$value": "50dp", - "px": 100.0, - "dp": 50.0, - "source": "index.html:231", - "usedBy": "person row in request sheets (version rows auto height, padding 20/26; track rows 84)" - }, - "settingsRow": { - "$type": "dimension", - "px": [ - 1300, - 104 - ], - "dp": [ - 650, - 52 - ], - "source": "index.html:321", - "usedBy": "Settings list row (icon 36, title 30/700, one-line description 24, pills, chevron)" - }, - "stateWidth": { - "$type": "dimension", - "$value": "550dp", - "px": 1100.0, - "dp": 550.0, - "source": "index.html:194", - "usedBy": "state panel width; padding 34/40; title 36; text 28" - }, - "captionOffset": { - "$type": "dimension", - "px": 38, - "dp": 19, - "source": "index.html:1242", - "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" - }, - "plateInset": { - "$type": "dimension", - "px": { - "top": 14, - "side": 16, - "bottomFade": 46 - }, - "available": "card width - 32", - "source": "index.html:162, index.html:1234", - "usedBy": "rating plate padding; the plate row must fit width - 32 px" - }, - "badge": { - "$type": "dimension", - "$value": "24dp", - "px": 48.0, - "dp": 24.0, - "source": "index.html:141", - "usedBy": "state badge circle, 14 px from right and bottom; glyph 28" - }, - "badgeInset": { - "$type": "dimension", - "$value": "7dp", - "px": 14.0, - "dp": 7.0, - "source": "index.html:141", - "usedBy": "badge inset from the card corner" - }, - "progressBar": { - "$type": "dimension", - "$value": "4dp", - "px": 8.0, - "dp": 4.0, - "source": "index.html:138", - "usedBy": "progress bar on wide cards (inset 18 left/right, 12 from bottom); title page bar is 320x8" - }, - "pickerAvatar": { - "$type": "dimension", - "$value": "110dp", - "px": 220.0, - "dp": 110.0, - "source": "index.html:261", - "usedBy": "Who's watching avatar; letter 100 px; avatars 90 px apart; name 26 px below" - }, - "pickerGap": { - "$type": "dimension", - "$value": "45dp", - "px": 90.0, - "dp": 45.0, - "source": "index.html:259", - "usedBy": "gap between people in the picker (also between gear slots)" - }, - "pickerStackGap": { - "$type": "dimension", - "$value": "35dp", - "px": 70.0, - "dp": 35.0, - "source": "index.html:256", - "usedBy": "vertical gap between \"Who's watching?\", the people and the gear row (gear row margin-top -34)" - }, - "gear": { - "$type": "dimension", - "$value": "28dp", - "px": 56.0, - "dp": 28.0, - "source": "index.html:306", - "usedBy": "Settings gear under the last person; glyph 30; caption 24 px, 72 px below the gear top, visible only on focus" - }, - "navAvatar": { - "$type": "dimension", - "$value": "32dp", - "px": 64.0, - "dp": 32.0, - "source": "index.html:90", - "usedBy": "avatar at the right end of the nav" - }, - "castCircle": { - "$type": "dimension", - "$value": "100dp", - "px": 200, - "dp": 100.0, - "source": "index.html:285", - "usedBy": "cast circle; name 26/700 at 216 px; character 24 muted; card 290 tall" - }, - "personPortrait": { - "$type": "dimension", - "$value": "120dp", - "px": 240.0, - "dp": 120.0, - "source": "index.html:294", - "usedBy": "person page portrait circle; header at x 96, y 170, gap 48" - }, - "thumb": { - "$type": "dimension", - "px": [ - 96, - 144 - ], - "dp": [ - 48, - 72 - ], - "source": "index.html:248", - "usedBy": "Coming-in poster thumbnail" - }, - "toast": { - "$type": "dimension", - "px": { - "right": 96, - "top": 150, - "maxWidth": 760 - }, - "source": "index.html:270", - "usedBy": "toast position (top right under the nav)" - }, - "letterRail": { - "$type": "dimension", - "px": { - "width": 52, - "letterHeight": 30, - "focusedLetterHeight": 44, - "right": 96, - "top": 150 - }, - "source": "index.html:204", - "usedBy": "letter rail (only for destinations with >= 200 titles)" - }, - "row_chipsGrid": { - "$type": "dimension", - "$value": "68dp", - "px": 136, - "dp": 68.0, - "source": "index.html:rowH", - "usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)" - } - }, - "card": { - "poster": { - "$type": "card", - "px": { - "w": 256, - "h": 384, - "gap": 40, - "pitch": 296, - "captionBand": 96 - }, - "dp": { - "w": 128.0, - "h": 192.0, - "gap": 20.0, - "pitch": 148.0, - "captionBand": 48.0 - }, - "ratio": "2:3", - "fullSlotsPerRow": 6, - "source": "index.html:656", - "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" - }, - "tile": { - "$type": "card", - "px": { - "w": 400, - "h": 225, - "gap": 40, - "pitch": 440, - "captionBand": 0 - }, - "dp": { - "w": 200.0, - "h": 112.5, - "gap": 20.0, - "pitch": 220.0, - "captionBand": 0.0 - }, - "ratio": "16:9", - "fullSlotsPerRow": 4, - "source": "index.html:657", - "usedBy": "Next up, New in your library, season episodes; title + second line on a bottom gradient" - }, - "wide": { - "$type": "card", - "px": { - "w": 544, - "h": 306, - "gap": 40, - "pitch": 584, - "captionBand": 0 - }, - "dp": { - "w": 272.0, - "h": 153.0, - "gap": 20.0, - "pitch": 292.0, - "captionBand": 0.0 - }, - "ratio": "16:9", - "fullSlotsPerRow": 3, - "source": "index.html:658", - "usedBy": "Continue watching (only when a person has more than one resume item; the first resume is the hero)" - }, - "entry": { - "$type": "card", - "px": { - "w": 256, - "h": 144, - "gap": 40, - "pitch": 296, - "captionBand": 0 - }, - "dp": { - "w": 128.0, - "h": 72.0, - "gap": 20.0, - "pitch": 148.0, - "captionBand": 0.0 - }, - "ratio": "16:9", - "fullSlotsPerRow": 6, - "source": "index.html:659", - "usedBy": "Discover Services row (text wordmark on a tinted gradient; no third-party logos)" - }, - "person": { - "$type": "card", - "px": { - "w": 200, - "h": 310, - "gap": 44, - "pitch": 244, - "captionBand": 0 - }, - "dp": { - "w": 100.0, - "h": 155.0, - "gap": 22.0, - "pitch": 122.0, - "captionBand": 0.0 - }, - "ratio": "1:1 circle", - "fullSlotsPerRow": 7, - "source": "index.html:660", - "usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)" - } - }, - "focus": { - "scaleCard": { - "$type": "number", - "$value": 1.08, - "source": "index.html:134", - "page": { - ".card.f": 1.08, - ".pp.f (picker person)": 1.08, - ".pgear.f": 1.08, - "person circle": 1.08 - }, - "usedBy": "cards of every kind, cast circles, picker people, the gear" - }, - "scaleControl": { - "$type": "number", - "$value": 1.05, - "source": "index.html:189", - "page": { - ".btn.f": 1.06, - ".chip.f": 1.05 - }, - "usedBy": "pill and icon buttons, chips", - "note": "q1 = 3 scales, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): buttons 1.05. The page button scale 1.06 folds into 1.05 (delta 0.01 = 2 px on a 200 px button). Spec 4.5, 12.4 #1." - }, - "scaleRow": { - "$type": "number", - "$value": 1.02, - "source": "index.html:327", - "page": { - ".prow.f": 1.03, - ".crow.f": 1.02, - ".srow.f": 1.02 - }, - "usedBy": "full-width list rows: sheet rows, Coming-in rows, Settings rows", - "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): sheet rows 1.02. The page sheet-row scale 1.03 folds into 1.02. Spec 4.5, 12.4 #1." - }, - "ringCard": { - "$type": "shadow", - "$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)", - "gapPx": 0, - "ringPx": 4, - "gapDp": 0, - "ringDp": 2, - "ringColor": "color.accent", - "liftShadow": "0 18px 50px rgb(0 0 0 / .65)", - "source": "index.html:134", - "usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")", - "outsidePx": 2, - "insidePx": 2 - }, - "ringControl": { - "$type": "shadow", - "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)", - "gapPx": 0, - "ringPx": 4, - "gapDp": 0, - "ringDp": 2, - "outsidePx": 2, - "insidePx": 2, - "alignment": "straddle", - "ringColor": "color.accent", - "liftShadow": "0 12px 40px rgb(0 0 0 / .5)", - "source": "index.html:111", - "usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged" - }, - "ringAvatar": { - "$type": "shadow", - "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", - "gapPx": 0, - "ringPx": 4, - "gapDp": 0, - "ringDp": 2, - "outsidePx": 2, - "insidePx": 2, - "alignment": "straddle", - "ringColor": "color.accent", - "source": "index.html:92,312", - "usedBy": "focused nav avatar and gear; existing scale unchanged, no lift" - }, - "ringPicker": { - "$type": "shadow", - "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", - "gapPx": 0, - "ringPx": 4, - "gapDp": 0, - "ringDp": 2, - "outsidePx": 2, - "insidePx": 2, - "alignment": "straddle", - "ringColor": "color.accent", - "source": "index.html:265", - "usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift" - }, - "fillInverse": { - "$type": "rule", - "$value": "background color.fg, text color.onFocus", - "source": "index.html:189, index.html:237, index.html:327, index.html:253", - "usedBy": "focused chip, sheet row, Settings row, Coming-in row and nav blob: white fill with dark text (no ring)" - } - }, - "motion": { - "fast": { - "$type": "duration", - "$value": "140ms", - "easing": "ease = cubic-bezier(.25,.1,.25,1)", - "source": "index.html:125", - "page": { - ".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms", - ".cap opacity (caption fade-in)": "120ms", - ".dots svg opacity": "140ms" - }, - "usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)", - "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring." - }, - "base": { - "$type": "duration", - "$value": "160ms", - "easing": "ease", - "source": "index.html:162", - "page": { - ".rplate opacity": "160ms", - "#nav background": "160ms", - ".track transform (row slide)": "160ms", - ".chips transform": "160ms", - ".dbg .dim opacity": "160ms", - ".toast opacity": "160ms" - }, - "usedBy": "rating plate fade, nav band solid/transparent, row track slide by one card, chip strip shift, title-page dim as it scrolls" - }, - "scroll": { - "$type": "duration", - "$value": "180ms", - "easing": "ease-out cubic: 1 - (1 - t)^3 = cubic-bezier(.33,1,.68,1)", - "source": "index.html:1076", - "usedBy": "vertical frame scroll to the pinned line (Compose BringIntoViewSpec animation spec)" - }, - "navBlob": { - "$type": "transition", - "$value": { - "type": "spring", - "dampingRatio": 1.0, - "stiffness": 650 - }, - "page": { - "transition": "transform .2s ease-out,width .2s ease-out,height .2s ease-out,background-color .2s ease-out", - "measured": "x 478 -> 688 px: 557.4 at 50 ms, 621.8 at 99 ms (68.5% of travel), 668.4 at 150 ms, at rest by 200 ms; width 198 -> 171 animates with it" - }, - "source": "index.html:76", - "usedBy": "nav indicator: x, width, height and colour (white when the nav has focus, rest colour behind the current destination when not); label colour follows (page .ni color .2s ease-out)", - "verified": { - "script": "data/tv-spec/spring_check.py", - "method": "mass 1, x'' = -k(x-1) - 2*zeta*sqrt(k)*x', integrated at 0.01 ms steps; k searched 400-1000 step 10 for least error against the page measurements", - "pxAt50_99_150_200ms": [ - 554.5, - 628.7, - 665.9, - 680.2 - ], - "pageMeasuredPx": [ - 557.4, - 621.8, - 668.4, - 688.0 - ], - "reaches95pctMs": 186, - "reaches99pctMs": 260, - "within1pxMs": 294, - "maxOvershoot": 0, - "previousStiffness": "530 (revision 1, reached 66.4% at 99 ms against the page 68.5%)" - }, - "note": "q1 = spring, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): a no-bounce spring matched to the measured 200 ms ease-out. Damping ratio 1.0 is critically damped, so it cannot overshoot. Stiffness 650 is the best fit to the measured curve (revision 1 had 530, an untested derivation). Visually settled (95%) by 186 ms; the last pixel arrives at about 300 ms. Snap with no animation when the system animator scale is 0." - }, - "ratingDwell": { - "$type": "duration", - "$value": "350ms", - "source": "index.html:860", - "usedBy": "a focused card requests ratings only after 350 ms of dwell; moving on cancels; cached ratings show at once (fork DEBOUNCE_MILLIS 350)" - }, - "toast": { - "$type": "duration", - "$value": "3200ms", - "source": "index.html:1733", - "usedBy": "toast visible time" - }, - "reducedMotion": { - "$type": "rule", - "$value": "all transitions instant", - "source": "index.html:330", - "usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps" - } - }, - "image": { - "posterRequest": { - "px": [ - 256, - 384 - ], - "decode": "RGB565", - "source": "index.html:548 (memory table), answers p3", - "usedBy": "poster cards; decode at min(source, slot); fixture sources are 300x450" - }, - "tileRequest": { - "px": [ - 416, - 234 - ], - "decode": "ARGB_8888", - "source": "index.html:549", - "usedBy": "tile cards" - }, - "wideRequest": { - "px": [ - 560, - 315 - ], - "decode": "ARGB_8888", - "source": "index.html:401", - "usedBy": "Continue watching cards" - }, - "heroRequest": { - "px": [ - 1280, - 720 - ], - "decode": "ARGB_8888", - "bytes": 3686400, - "source": "index.html:549", - "usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" - }, - "castRequest": { - "px": [ - 200, - 200 - ], - "source": "index.html:550", - "usedBy": "cast portraits: decode min(source 185x278, slot)" - }, - "crossfade": { - "$value": false, - "source": "index.html:536; answers p3", - "usedBy": "no crossfade on cards (Coil crossfade off)" - }, - "memoryCacheMB": { - "$value": 64, - "source": "index.html:552; answers p3", - "usedBy": "Coil memory cache cap (not a heap percentage)" - }, - "diskCacheMB": { - "$value": 128, - "source": "index.html:553", - "usedBy": "Coil disk cache" - }, - "concurrentReads": { - "$value": { - "catalogue": 2, - "image": 4 - }, - "source": "index.html:489 (Rules row)", - "usedBy": "at most 2 catalogue reads and 4 image reads at once" - } - }, - "behaviour": { - "rowLimit": { - "$value": 20, - "source": "index.html:666", - "usedBy": "cards per shelf before See all (D3)" - }, - "letterRailMin": { - "$value": 200, - "source": "index.html:667", - "usedBy": "the letter rail exists only for a destination of >= 200 titles, sort A-Z (n4)" - }, - "gridColumns": { - "$value": 6, - "source": "index.html:1425", - "usedBy": "library, See all and person grids" - }, - "libraryChunk": { - "$value": 200, - "source": "index.html:469", - "usedBy": "library read in 200-item chunks ahead of focus" - }, - "laneLoadRows": { - "$value": 2, - "source": "index.html:472", - "usedBy": "catalogue lane is read when within 2 rows of focus" - }, - "nextPageCards": { - "$value": 10, - "source": "index.html:472", - "usedBy": "catalogue page 2+ is read when focus is 10 cards from the row end" - }, - "heroSlides": { - "$value": 5, - "source": "index.html:883", - "usedBy": "Discover picks hero slides" - }, - "cacheRatingsHours": { - "$value": 6, - "entries": 300, - "source": "index.html:473", - "usedBy": "ratings cache per (type, tmdb) and per (series, season)" - }, - "laneCacheMinutes": { - "$value": 30, - "source": "index.html:472", - "usedBy": "last-known-good per catalogue lane" - }, - "libraryRefreshMinutes": { - "$value": 10, - "source": "index.html:469", - "usedBy": "library snapshot refreshed on open if older than 10 min" - }, - "requestsPollSeconds": { - "$value": 30, - "source": "index.html:479", - "usedBy": "Requests (Coming in) poll while a request is unsettled or the sheet is open" - }, - "comingInVisibleRows": { - "$value": 5, - "expanded": 4, - "source": "index.html:1907", - "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" - } - } -} diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 deleted file mode 100644 index b3fabec1..00000000 --- a/wolfskin-design/tokens.sha256 +++ /dev/null @@ -1,8 +0,0 @@ -1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json -85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py -25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json -be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json -b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css -3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json